diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 9e1a710..6aa7960 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,102 +1,102 @@ -{"_type":"issue","id":"hm-68a","title":"introduce an element that is to be used anywhere where i am typing a reference to a secret. it should autocomplete as i type, showing me the closest match based on levenshtein distance. the same code should be used to also add a 'did you mean X' when himitsu search returns no results","status":"open","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-07T06:22:32Z","created_by":"Cooper Maruyama","updated_at":"2026-05-07T06:22:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-7ob","title":"Update command palette layout","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-07T06:06:18Z","created_by":"Cooper Maruyama","updated_at":"2026-05-07T06:10:27Z","started_at":"2026-05-07T06:06:18Z","closed_at":"2026-05-07T06:10:27Z","close_reason":"Updated command palette layout and copy","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-isi","title":"add support for TUI hints - a small bit of text that can render in subtle text on the bottom left corner, sort of like a floating element","status":"open","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-06T23:21:58Z","created_by":"Cooper Maruyama","updated_at":"2026-05-06T23:21:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-3w5","title":"all form fields should have placeholders containing an example","status":"open","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-06T23:20:58Z","created_by":"Cooper Maruyama","updated_at":"2026-05-06T23:20:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-3rr","title":"add a submit button to the new secret tui, and add a confirm dialog when clicking esc that lets you save or discard","status":"open","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-06T23:20:25Z","created_by":"Cooper Maruyama","updated_at":"2026-05-06T23:20:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-6if","title":"Add tags support for secrets (search + env composition)","description":"## Motivation\n\nToday a secret's only addressable axis is its path. That works for hierarchies (`prod/api/STRIPE_KEY`) but not for crosscutting groupings — \"everything PCI\", \"everything the mobile team owns\", \"rotate-this-quarter\". Users currently fake it with path conventions, which collide with the env-prefix model.\n\nAdd free-form tags to secrets so that:\n- An item can carry many tags (`[\"pci\", \"stripe\", \"mobile\"]`).\n- `himitsu search` and `himitsu ls` can filter by tag.\n- The env DSL can select by tag, so an env like `pci-prod` can be authored as `tag: pci` instead of enumerating paths.\n\n## Data model\n\nTags live on `SecretValue` (the encrypted payload), as `repeated string tags = 9` in `proto/secrets.proto`. They travel with the value, are written/read through the existing age envelope, and stay atomic with the secret they describe.\n\n`SecretEntry.tags` (already declared at `proto/secrets.proto:89`) is repurposed as the manifest projection of the same field — populated by listing code that decrypts to surface metadata, identical to how descriptions are currently surfaced in `cli::search::search_core`.\n\nBackwards compatibility: empty tags is the default; legacy envelopes round-trip through `crypto::secret_value::decode` unchanged.\n\nTag grammar: `[A-Za-z0-9_.-]+`, 1–64 chars, no whitespace, case-sensitive. Validated at write time.\n\n## Authoring surfaces\n\n### CLI\n- `himitsu set \u003cpath\u003e \u003cvalue\u003e --tag \u003ct\u003e --tag \u003ct\u003e ...` — repeatable flag on `cli/set.rs`.\n- `himitsu write \u003cpath\u003e --tag \u003ct\u003e` — same flag wired through `cli/write.rs`.\n- `himitsu tag \u003cpath\u003e add \u003ctag\u003e...` / `rm \u003ctag\u003e...` / `list` — new subcommand at `rust/src/cli/tag.rs`, registered in `cli/mod.rs`. Mutates by decrypt → edit `tags` → re-encrypt → write.\n\n### TUI\n- `tui/views/new_secret.rs`: add a `Tags` step (comma-separated input, validated per char as the user types) between `Description` and `EnvKey`.\n- `tui/views/secret_viewer.rs`: render tags as chips in the metadata pane.\n- `tui/views/search.rs`: render tag chips next to each result row; clicking/pressing a chip refines the search.\n\n## Search \u0026 filter\n\n- `himitsu search \u003cquery\u003e [--tag t]...`: AND-semantics across tag flags; empty query + tags = \"all secrets with these tags\". Tags read off the same `Decoded` struct produced by `search_core` decrypt loop.\n- `himitsu ls --tag t [--tag t]...`: same AND filter, applied after path-prefix walk.\n- Query-string sugar (out of scope for this epic, file as follow-up): `tag:foo bar` syntax inside the freeform query.\n\n## Env DSL — new `Tag` entry\n\nExtend `EnvEntry` (`rust/src/config/mod.rs:191`) with a `Tag(String)` variant. YAML shapes the deserializer accepts (extending `EnvEntry::deserialize` at `mod.rs:215`):\n\n- Inline string with `tag:` prefix:\n ```yaml\n envs:\n pci-prod:\n - tag:pci\n - tag:prod\n ```\n AND-semantics across multiple `tag:` entries inside one env (consistent with CLI `--tag` repetition).\n\n- Map form for explicit alias-rename, parallel to existing alias:\n ```yaml\n envs:\n pci-prod:\n - { tag: pci } # all pci-tagged secrets, key = last path segment\n - { STRIPE: tag:stripe } # alias: env-key STRIPE := the (singular) stripe-tagged secret\n ```\n Map-form must error when more than one secret matches the tag (alias requires a single value).\n\nResolver (`rust/src/config/env_resolver.rs:63`) gains a parallel branch to `resolve_concrete` / `resolve_wildcard`: walk `available_secrets`, decrypt-or-cache to read `tags`, include those that match. Wildcard capture refs (`$1`) are not extended to tag entries in this epic — out of scope.\n\n## Out of scope\n- Query-string `tag:` sugar inside `search \u003cquery\u003e` (file as follow-up after this lands).\n- Sharing/inheriting tags across stores via policy.\n- Tag autocomplete in the TUI tag input (nice-to-have follow-up).\n- Capture-ref interpolation inside `Tag` env entries.\n- Renaming/merging tags as a first-class operation (do it manually via `tag rm` + `tag add`).\n\n## Success criteria\n- `himitsu set foo --tag a --tag b` round-trips: `himitsu get foo` shows tags in the metadata block, `himitsu tag foo list` prints `a\\nb`.\n- `himitsu search --tag a` returns only secrets carrying tag `a`; same for `ls --tag a`.\n- `himitsu codegen \u003cenv\u003e` against an env with `- tag:a` produces a SOPS bundle whose keys are exactly the secrets tagged `a`.\n- TUI new-secret form accepts comma-separated tags; secret-viewer renders them; search rows show tag chips.\n- All existing tests pass; new tests cover: tag validation, `EnvEntry` (de)serialization round-trip for both string and map forms, resolver behavior with tag entries, `search`/`ls` filter logic.\n- Legacy secrets (no tags field) decode unchanged.\n","status":"open","priority":2,"issue_type":"epic","owner":"noreply@anthropic.com","created_at":"2026-05-05T12:14:38Z","created_by":"Claude","updated_at":"2026-05-05T12:14:38Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-scm","title":"instead of adding a row and indenting items that have a common prefix, add a key to fold/unfold by 1 level, get rid of the extra row, and use a sutble accent to show when folded/unfolded","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-04T12:25:10Z","created_by":"Cooper Maruyama","updated_at":"2026-05-05T03:57:37Z","started_at":"2026-05-04T12:25:35Z","closed_at":"2026-05-05T03:57:37Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-bs1","title":"footer items are not spaced evenly - right-most item should be right-aligned","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-04T12:12:52Z","created_by":"Cooper Maruyama","updated_at":"2026-05-04T12:14:30Z","started_at":"2026-05-04T12:13:19Z","closed_at":"2026-05-04T12:14:30Z","close_reason":"render_distributed_footer now anchors first item Left and last item Right so the footer row reads as evenly distributed across the full width","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-dsa","title":"ctrl+P command menu should contain all valid commands from the stateless CLI","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-04T12:12:25Z","created_by":"Cooper Maruyama","updated_at":"2026-05-05T03:57:37Z","started_at":"2026-05-04T12:25:22Z","closed_at":"2026-05-05T03:57:37Z","close_reason":"Ctrl+P palette now lists every visible top-level CLI command. Wired commands (new/sync/rekey/join/add-remote/switch-store/envs/help/quit/toggle-store-column) keep their behavior. New entries (recipient ls/add/rm/show, remote list/remove/default, context show/set/clear, generate, export, check, docs, import, git) emit an info toast naming the equivalent CLI invocation; full TUI forms can be wired per-command as follow-up.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-8jm","title":"fix himitsu sync failing in the current state","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-04T12:09:41Z","created_by":"Cooper Maruyama","updated_at":"2026-05-04T12:17:24Z","started_at":"2026-05-04T12:11:24Z","closed_at":"2026-05-04T12:17:24Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-mw9","title":"add any compatible commands from the stateless CLI to the command picker in the TUI","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-04T01:15:29Z","created_by":"Cooper Maruyama","updated_at":"2026-05-04T01:18:27Z","started_at":"2026-05-04T01:15:44Z","closed_at":"2026-05-04T01:18:27Z","close_reason":"Added sync, rekey, and join commands to the TUI command palette","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-1wl","title":"himitsu import should convert SOME_KEY to some-key. it should also allow me to do a simple glob filter like foo/* and only import matches, and then it should also let me (unless it does already) set a prefix like --to some/target which would create some/target/{foo,bar,...} or --to github:org/repo#foo would put them in the foo directory. also, --to foo/bar should be equivalent to --to foo/bar/ (the trailing slash is ignored)","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-04T01:01:12Z","created_by":"Cooper Maruyama","updated_at":"2026-05-04T01:07:04Z","started_at":"2026-05-04T01:01:36Z","closed_at":"2026-05-04T01:07:04Z","close_reason":"Implemented key normalization (SOME_KEY→some-key), --filter glob, --to prefix, and --keep-names opt-out","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-u0v","title":"auto-detect if recipient list doesn't contain own age key","description":"## Context\n in that case, the sync status should glow orange and tell you to run 'himitsu join' which would simply add own public key to the stores recipient list, commit, and push. github actions should then rekey in a self-serve way if it is configured with a key in secrets. adding this key to secrets is also something that himitsu can do for you automatically. this task can be seen as idempotently ensuring those 3 conditions are met","notes":"Scope per description: idempotently ensure (1) own pubkey is in the store's recipient list, (2) GitHub Actions has a configured age key in repo secrets, (3) auto-rekey via Actions is wired up. Surface state via TUI store-health glow + 'himitsu join' command that performs (1)-(3). Significant product/UX design — defer until owner aligns on join command shape and Actions self-serve flow.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-03T21:56:09Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:12:52Z","started_at":"2026-05-03T22:06:52Z","closed_at":"2026-05-03T22:12:52Z","close_reason":"Implemented himitsu join command and TUI NotRecipient health indicator","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-bqk","title":"himitsu isn't automatically running init when user has no store","notes":"Investigation 2026-05-03: tui::run (rust/src/tui/mod.rs:33) auto-launches the wizard only on missing key (~/.local/share/himitsu/key). Gap: a user with a key but no resolved store gets an empty dashboard. Fix would be to also trigger run_init_flow when ctx.store.as_os_str().is_empty(). Risk: run_init_flow currently calls run() recursively after the wizard completes (mod.rs:126); init can complete-and-Success without creating a store if --name/--project are not used in the wizard, which would create an infinite loop. Either (a) require --name in the wizard's success path, (b) only re-enter run() when wizard reports a store was created, or (c) return Ok(()) from run() when store is still empty post-wizard. Needs interactive testing.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-03T21:52:55Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:07:19Z","started_at":"2026-05-03T22:05:14Z","closed_at":"2026-05-03T22:07:19Z","close_reason":"TUI now launches init flow when key exists but no store resolves, with post-wizard no-store guard.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-4y5","title":"Cross-repo ref: prompt to clone missing referenced store","description":"When a qualified cross-repo reference (github:org/repo#path) hits a store that isn't checked out locally, prompt for confirmation before cloning. Interactive: y/N prompt (default no). Non-interactive (no TTY): error with hint to run remote add. Add --yes to bypass confirmation for scripted use. TUI: small modal.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T10:55:51Z","created_by":"Cooper Maruyama","updated_at":"2026-05-01T10:57:55Z","started_at":"2026-05-01T10:56:38Z","closed_at":"2026-05-01T10:57:55Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-w6c","title":"Cross-repo ref: prefer github:org/repo#path syntax (accept legacy / form)","description":"Switch the canonical cross-repo reference syntax from github:org/repo/path/to/secret to github:org/repo#path/to/secret to disambiguate the slug from the path. Parser must accept both forms; the # form is the canonical one (used in docs, error messages, codegen). The slash form continues to parse as a fallback.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T10:55:51Z","created_by":"Cooper Maruyama","updated_at":"2026-05-01T10:57:55Z","started_at":"2026-05-01T10:55:55Z","closed_at":"2026-05-01T10:57:55Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-k4c","title":"init should have placeholder for config set to project if in a git repo, unless the user has not configured a global store yet","description":"In the init wizard's remote-store step, when inside a git repo (or parent is), default the placeholder to the current repo's origin slug and write to \u003cgit_root\u003e/himitsu.yaml as project-scoped config. Always run the global step first (skipped if already configured), labeled 'configure global store', then run a separate 'configure project' step (skipped if not in a git repo).","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-01T10:29:47Z","created_by":"Cooper Maruyama","updated_at":"2026-05-01T10:42:26Z","closed_at":"2026-05-01T10:42:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-anm","title":"Expand README sync and store health docs","description":"Document sync behavior, auto-commit/push, no-remote and not-pushed warnings, --no-push, auto_pull, and TUI store health states.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:51:23Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:01:09Z","started_at":"2026-05-03T21:59:14Z","closed_at":"2026-05-03T22:01:09Z","close_reason":"Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync \u0026 Store Health section (auto-commit/push, --no-push, store health states), new Demo \u0026 Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-1lt","title":"Make README quick start TUI-first","description":"Revise README Quick Start so the primary path launches the TUI first, then presents CLI commands as scripting/automation alternatives.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:51:22Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:01:09Z","started_at":"2026-05-03T21:59:15Z","closed_at":"2026-05-03T22:01:09Z","close_reason":"Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync \u0026 Store Health section (auto-commit/push, --no-push, store health states), new Demo \u0026 Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-qku","title":"Document TUI theme and key configuration","description":"Add README documentation for global config TUI settings, including tui.theme, tui.keys, accepted built-in themes, and HIMITSU_TUI_THEME override.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:51:22Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:01:09Z","started_at":"2026-05-03T21:59:15Z","closed_at":"2026-05-03T22:01:09Z","close_reason":"Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync \u0026 Store Health section (auto-commit/push, --no-push, store health states), new Demo \u0026 Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-o89","title":"Fix README search documentation","description":"Update README search feature and command docs to reflect current behavior: search reads store files directly and --refresh is retained as a no-op compatibility flag, not a SQLite index rebuild.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:51:21Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:01:09Z","started_at":"2026-05-03T21:59:16Z","closed_at":"2026-05-03T22:01:09Z","close_reason":"Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync \u0026 Store Health section (auto-commit/push, --no-push, store health states), new Demo \u0026 Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-1b7","title":"Review README and stabilize demo sync state","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:37:30Z","created_by":"Cooper Maruyama","updated_at":"2026-05-01T00:40:56Z","started_at":"2026-05-01T00:37:37Z","closed_at":"2026-05-01T00:40:56Z","close_reason":"Reviewed README and added demo sync-state override","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-2f9","title":"Refresh VHS demo tapes","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-30T23:45:18Z","created_by":"Cooper Maruyama","updated_at":"2026-05-01T00:03:22Z","started_at":"2026-04-30T23:45:23Z","closed_at":"2026-05-01T00:03:22Z","close_reason":"Refreshed VHS demo as sourced pure TUI tape and validated render","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-wqb","title":"allow editing name of items","description":"In the himitsu TUI, pressing Enter on an item opens an editor that lets you edit every field EXCEPT the item's name. Make the name field editable too.","status":"in_progress","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T23:19:04Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T23:23:03Z","started_at":"2026-04-30T23:23:03Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-6qj","title":"revamp envs feature.","description":"## Context\n envs should ultimately be stored like this:\n# config.yaml\n...\nenvs:\n my-env:\n - SOME_KEY: some-exact-match # optionally override env key\n - other-exact-match # uses env key set on item or fallsback to upper(replace(name, -,_)) (forward slash replaced with __)\n - arbitrary-glob/*\nmy-env-{dev,prod,stg}:\n - SOME_KEY: ref-requires-{} # requires specifying where to replace\n - some-group/item-name-{} # using the {} is not required if you have a better format\n\n\nTherefore, the env editor should have an initial browser where i can select an existing env or add a new one, then take me into a 2-panel layout where I am editing the left side as if it was hx and the right pane shows me a preview of what env would be generated based on matches and/or any errors. there should be fuzzy find support and some auto-complete like feature that lets me discover the right labels as im editing","status":"in_progress","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T23:16:44Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T23:23:04Z","started_at":"2026-04-30T23:23:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-5l4","title":"Highlight self in recipient ls","description":"Update himitsu recipient ls output so recipient rows whose full age public key matches the current user's own public key are highlighted in TTY output without changing non-TTY/plain output.","status":"closed","priority":2,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T12:26:21Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T12:28:59Z","started_at":"2026-04-30T12:26:43Z","closed_at":"2026-04-30T12:28:59Z","close_reason":"Highlighted recipient ls rows whose full public key matches the local self public key in TTY output, preserving plain non-TTY output","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-cak","title":"Fix search footer alignment and centering","description":"Apply split-layout footer alignment to the search view and correct small-screen search canvas centering.","status":"closed","priority":2,"issue_type":"bug","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T11:01:04Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T11:02:44Z","started_at":"2026-04-30T11:01:33Z","closed_at":"2026-04-30T11:02:44Z","close_reason":"Aligned search footer right actions with split layout and replaced Layout-based canvas centering with deterministic centered Rect math","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-774","title":"all screens should be the same size as the search view - apply whatever constraints the search view have globally","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T04:01:29Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T10:35:08Z","started_at":"2026-04-30T10:29:32Z","closed_at":"2026-04-30T10:35:08Z","close_reason":"Added shared standard_canvas margin/max-size helper and applied search view constraints across TUI screens and overlays","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-w6y","title":"Review and prune stale tests","description":"Review the current test suite and remove tests that cover deprecated behavior, stale expectations, or backwards-compatibility-only behavior no longer relevant to the Rust rewrite.","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-30T03:52:16Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T04:03:23Z","started_at":"2026-04-30T03:52:27Z","closed_at":"2026-04-30T04:03:23Z","close_reason":"Pruned stale/deprecated/backwards-compat test coverage and verified test suite passes","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-aqi","title":"Submodule-aware commit/push in dispatcher","description":"Enable single-store + git-submodule-for-includes workflow. Previously, himitsu set to a path inside a submodule would strand the file: submodule dirty, parent clean, nothing pushed. Dispatcher now commits submodules first, pushes them first, holds back parent push if any submodule push fails. auto_pull gains --recurse-submodules. Safe detached-HEAD auto-checkout only when HEAD matches origin default-branch tip.","status":"closed","priority":2,"issue_type":"feature","owner":"demo@himitsu.dev","created_at":"2026-04-24T09:18:44Z","created_by":"Cooper Maruyama","updated_at":"2026-04-24T09:19:01Z","closed_at":"2026-04-24T09:19:01Z","close_reason":"Landed: list_submodules/has_unpushed_commits/ensure_on_branch helpers in git.rs; Context::commit walks dirty submodules then parent, Context::push submodules-first with hold-back on failure, pull_if_remote recurses. 4 new tests covering round-trip with real local bare remotes. 460 tests green.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-fy3","title":"himitsu git status should never have unstaged / working changes. every action should commit, and make the git history be essentially an append-only log","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-21T22:13:42Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T22:40:35Z","closed_at":"2026-04-21T22:40:35Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-ic2","title":"himitsu codegen \u003cenv\u003e emits \u003cenv\u003e.sops.yaml","description":"Extend `himitsu codegen` (rust/src/cli/codegen.rs) to accept an env label and emit a valid SOPS file at `\u003cenv\u003e.sops.yaml`. Today codegen's `--env` flag is already threaded through (see rust/src/cli/codegen.rs:55-56) for language bindings; this task adds a new output mode where the env itself is the target.\n\n## CLI surface\n\n- `himitsu codegen \u003cenv\u003e` — shorthand for `--lang sops --env \u003cenv\u003e --output \u003cenv\u003e.sops.yaml`.\n- `himitsu codegen \u003cenv\u003e --output path.yaml` — override output path.\n- Accepts concrete labels (`foo/dev`) and wildcards (`foo/*`); wildcard form emits one file per matched child (`foo-dev.sops.yaml`, `foo-prod.sops.yaml`), or a single merged file if `--merge` is given (decide in review).\n\n## Output format\n\n- Top-level keys come from the resolved env entries (hm-qjz), each mapped to its decrypted secret value.\n- File is encrypted via `sops` rules: either the project's existing `.sops.yaml` rules file or an inline `creation_rules` block derived from himitsu recipients (TBD — call out in design doc).\n- Generated file contains the same `AUTO-GENERATED` header banner codegen already emits (rust/src/cli/codegen.rs:537).\n\n## Acceptance\n\n- `himitsu codegen foo/dev` produces `foo-dev.sops.yaml` that `sops -d` can decrypt with the configured recipients.\n- Wildcard case produces the expected file set.\n- Errors clearly when the env label is unknown or when captures fail to resolve.","notes":"## Integration decision (2026-04-21)\n\n**Shell out to the `sops` CLI.** Generate plaintext YAML from the resolved env tree, write it to the output path, then invoke `sops --encrypt --in-place \u003cpath\u003e`. Key/recipient resolution is delegated to the user's existing `.sops.yaml` rules file.\n\n- Requires `sops` on PATH — detect and emit a helpful error if missing.\n- No new crypto crate dependency.\n- Existing `AUTO-GENERATED` header banner applies to the plaintext emission before encryption.","status":"closed","priority":2,"issue_type":"feature","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:39:24Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T21:32:38Z","closed_at":"2026-04-21T21:32:38Z","close_reason":"Landed: codegen \u003cenv\u003e positional triggers sops path. Resolver-\u003edecrypt-\u003eYAML-\u003esops encrypt-in-place. Missing-sops error has install hint. 6 new tests green, 1 ignored smoke.","dependencies":[{"issue_id":"hm-ic2","depends_on_id":"hm-20e","type":"blocks","created_at":"2026-04-21T13:39:42Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-ic2","depends_on_id":"hm-r2e","type":"parent","created_at":"2026-04-21T13:39:45Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-20e","title":"Env resolver: expand wildcards and capture references","description":"Central pure-function resolver that both the TUI preview and `himitsu codegen \u003cenv\u003e` depend on. Given the full env map + a target label, return the concrete resolved entries (key → secret path, no wildcards, no captures remaining).\n\n## Behavior\n\n- Input: `\u0026Config.envs`, target label (e.g. `foo/*` or `foo/dev`).\n- Resolution:\n 1. If label is concrete, return its entries verbatim (Aliases already resolved to path refs).\n 2. If label ends in `/*`, enumerate sibling envs whose labels match `foo/\u003csegment\u003e` (or deeper — TBD in schema design), and for each child, substitute captures into the glob's entries (e.g. `\\$1` → the matched segment).\n 3. Merge overlapping keys: concrete child entries win over wildcard-inherited ones.\n- Output: `Vec\u003cResolvedEntry { key: String, secret_path: String }\u003e` plus diagnostics for unresolved captures or missing targets.\n\n## Why a dedicated resolver\n\nCodegen (hm-nm9) and the TUI preview (hm-kli) both need identical semantics. Putting it in one place means users never see a mismatch between TUI preview and generated SOPS output.\n\n## Acceptance\n\n- Table-driven tests for: concrete label, wildcard with 0/1/N children, nested wildcards (if allowed by schema), captures in Alias paths, capture index out of range.\n- Resolver returns deterministic order (sort by key) so generated SOPS files are diff-friendly.","notes":"## Output shape correction (2026-04-21)\n\nThe resolver returns a **nested tree**, not a flat `Vec\u003cResolvedEntry\u003e`. Shape (proposed):\n\n```rust\nenum EnvNode {\n Leaf { secret_path: String },\n Branch(BTreeMap\u003cString, EnvNode\u003e),\n}\n```\n\nReasoning: `foo/*` expansion over children `foo/bar` and `foo/bar/baz` must produce a single nested structure, not a flat list and not multiple resolver invocations. Captures (`\\$1`) still substitute path segments, but the output is always a tree rooted at the queried label.\n\nGlob entries reference secret variables only — never other env groups. The resolver does not follow cross-env references.\n\nDeterminism: `BTreeMap` gives sorted keys → diff-friendly downstream codegen.","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:39:15Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T21:12:58Z","closed_at":"2026-04-21T21:12:58Z","close_reason":"Wave 2 landed in commit. Resolver returns deterministic EnvNode trees for concrete + wildcard labels. Cache mirrors envs into SQLite with scope isolation, SHA-256 staleness, atomic refresh. 18 new tests green.","dependencies":[{"issue_id":"hm-20e","depends_on_id":"hm-5v4","type":"blocks","created_at":"2026-04-21T13:39:39Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-20e","depends_on_id":"hm-r2e","type":"parent","created_at":"2026-04-21T13:39:44Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} -{"_type":"issue","id":"hm-zzj","title":"TUI Envs tab: list, create, edit, delete presets","description":"Add a dedicated Envs tab alongside the existing TUI views (see rust/src/tui/views/). It becomes the primary UX for authoring preset environments.\n\n## UX sketch\n\n- Left pane: tree of env labels grouped by scope (project first, then global). `foo`, `foo/bar`, `foo/*` render as a hierarchy.\n- Right pane: the selected env's resolved entries — Singles, Aliases, and the expanded form of any Glob (so users see what `foo/*` actually produces).\n- Keymap reuses the project's existing conventions (rust/src/tui/keymap.rs): `n` new env, `e` edit, `d` delete, `/` search, `Enter` drill in.\n- Inline editor for entry kinds (Single | Glob | Alias) with a live preview column showing capture-group expansions.\n\n## Wiring\n\n- Reads come from the SQLite cache (hm-8ht).\n- Writes go through the mutation layer (hm-d1m); optimistic UI with toast rollback on failure.\n- Scope indicator visible at all times; creation defaults to project scope inside a git repo, global otherwise.\n\n## Acceptance\n\n- Can create `foo`, `foo/bar`, and `foo/*` entries end-to-end from the TUI.\n- Editing an Alias with a capture ref renders the live expansion for each matched child env.\n- Deleting an env removes it from YAML + cache on disk.","notes":"## Scope reduction (2026-04-21)\n\n**First slice: read-only + delete.** Creation and inline editing are deferred to follow-up issues (to be filed when this one lands).\n\nIncluded in v1:\n- Tree/list view of envs grouped by scope (project/global).\n- Right pane shows resolved entries (Singles, Aliases, expanded Globs via hm-20e).\n- `d` deletes the selected env (with confirmation), routed through the mutation API (hm-d1m).\n- `/` search, scope indicator, navigation.\n\nDeferred to follow-ups:\n- `n` new env flow with inline editor.\n- `e` edit existing entries.\n- Live capture-reference preview editor.\n\nRationale: smallest slice that validates the cache + resolver + mutation stack end-to-end through real UI. Creation UX deserves its own design pass once read-path is proven.","status":"closed","priority":2,"issue_type":"feature","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:39:06Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T21:57:31Z","closed_at":"2026-04-21T21:57:31Z","close_reason":"Read+delete TUI tab landed. Epic complete: schema + cache + resolver + mutation + codegen sops + TUI all merged. 5 new view tests; 337 lib tests pass.","dependencies":[{"issue_id":"hm-zzj","depends_on_id":"hm-20e","type":"blocks","created_at":"2026-04-21T13:39:41Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-zzj","depends_on_id":"hm-8ht","type":"blocks","created_at":"2026-04-21T13:39:40Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-zzj","depends_on_id":"hm-d1m","type":"blocks","created_at":"2026-04-21T13:39:40Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-zzj","depends_on_id":"hm-r2e","type":"parent","created_at":"2026-04-21T13:39:45Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-d1m","title":"Config mutation layer: write envs to project or global scope","description":"Add a mutation API that the TUI and any future CLI subcommands call to create/update/delete env presets. Scope is inferred the same way `codegen` already does it (rust/src/cli/codegen.rs:118-127): walk up for `.himitsu.yaml`, else fall back to global config.\n\n## Requirements\n\n- Single entry point: `envs::upsert(label, entries, scope_hint) -\u003e Result\u003c()\u003e`; sibling `envs::delete(label, scope)`.\n- Preserve YAML formatting/comments where feasible (serde_yaml round-trip is lossy — evaluate `yaml-rust2` or string-level edits if comments matter here).\n- Atomic writes: temp file + rename so a crash mid-write does not corrupt config.\n- After every mutation, refresh the SQLite cache row for that scope.\n- Validation: reject labels that violate the grammar from the schema-design task.\n\n## Acceptance\n\n- Unit tests for project vs global routing, label validation, atomic write behavior.\n- Integration test: mutate via API → reload `Config` from disk → observe change.","notes":"## YAML fidelity decision (2026-04-21)\n\n**Accept lossy round-trip via `serde_yaml`.** Comments and custom formatting in `.himitsu.yaml` / global config will not survive a mutation. This matches the current codebase pattern and keeps the implementation small.\n\n- Document the lossy behavior in the TUI help panel + user-facing docs.\n- Atomic write (temp file + rename) still required to avoid partial writes.","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:38:54Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T21:23:55Z","closed_at":"2026-04-21T21:23:55Z","close_reason":"Landed: upsert/delete/read API with ScopeHint inference, atomic writes, validation, cache refresh. Config gained first-class envs field. 11 new tests green.","dependencies":[{"issue_id":"hm-d1m","depends_on_id":"hm-5v4","type":"blocks","created_at":"2026-04-21T13:39:37Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-d1m","depends_on_id":"hm-8ht","type":"blocks","created_at":"2026-04-21T13:39:38Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-d1m","depends_on_id":"hm-r2e","type":"parent","created_at":"2026-04-21T13:39:44Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"hm-8ht","title":"SQLite cache for env presets (project + global scope)","description":"Mirror `envs:` from YAML config into SQLite so the TUI can list/search without re-parsing YAML on every keystroke, and so other subsystems (codegen, check) have a uniform read API.\n\n## Requirements\n\n- Tables (proposed):\n - `envs(id INTEGER PK, label TEXT, scope TEXT CHECK(scope IN ('project','global')), config_path TEXT, mtime INTEGER)`\n - `env_entries(id INTEGER PK, env_id INTEGER REFERENCES envs(id) ON DELETE CASCADE, kind TEXT, value TEXT, alias_key TEXT)`\n- Rebuild strategy: on load, compare config file hash/mtime against cached row; rebuild if stale.\n- Scope handling: project rows keyed by git-root path so switching projects does not pollute.\n- YAML remains source of truth — every write path must rewrite YAML then refresh the cache; never the reverse.\n\n## Non-goals\n\n- No schema migrations framework yet — initial table ships with the feature.\n- No cross-device sync; the cache is purely local.\n\n## Acceptance\n\n- Round-trip test: load YAML → cache → query → matches parsed `Config.envs`.\n- Stale detection: mutating YAML on disk triggers cache rebuild on next read.","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:38:47Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T21:12:58Z","closed_at":"2026-04-21T21:12:58Z","close_reason":"Wave 2 landed in commit. Resolver returns deterministic EnvNode trees for concrete + wildcard labels. Cache mirrors envs into SQLite with scope isolation, SHA-256 staleness, atomic refresh. 18 new tests green.","dependencies":[{"issue_id":"hm-8ht","depends_on_id":"hm-5v4","type":"blocks","created_at":"2026-04-21T13:39:36Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-8ht","depends_on_id":"hm-r2e","type":"parent","created_at":"2026-04-21T13:39:43Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} -{"_type":"issue","id":"hm-5v4","title":"Design env schema: wildcard semantics + capture references","description":"Formalize the env label/entry model before any TUI or codegen work lands. The current `EnvEntry` enum (rust/src/config/mod.rs:134) has `Single`, `Glob`, `Alias` — decide how capture references like `/\\$1/postgres-url` are expressed on-disk and in-memory.\n\n## Questions to resolve\n\n- Label grammar: confirm `foo`, `foo/bar`, `foo/*` are the only legal forms; reject `foo/*/bar`? Or allow mid-path wildcards?\n- Does a `foo/*` env enumerate its children by scanning other env labels in config, or by scanning the secret namespace? (Probably: scan sibling env labels that match `foo/\u003csomething\u003e`.)\n- Capture-ref syntax: `\\$1`, `\\${1}`, `{{1}}`? Which path segments are captured — everything matched by `*`?\n- Do captures only make sense inside `Glob` entries, or also inside `Alias.path`? Likely the latter: `Alias { key: DB_URL, path: \\\"/\\$1/postgres-url\\\" }` under a `foo/*` env.\n- Round-trip serde: new `EnvEntry` variant or reuse existing `Glob`/`Alias` with pattern detection?\n\n## Deliverable\n\nA short design doc (in this issue's `--design`) + updated serde tests in rust/src/config/mod.rs covering:\n- Capture-ref in Alias paths\n- Invalid label rejection\n- Expansion semantics (input env set → resolved concrete entries)","notes":"## Locked constraints (2026-04-21)\n\n- **Reject mid-path wildcards.** Only `foo`, `foo/bar`, `foo/*` are valid. `foo/*/bar`, `*/foo`, and similar are hard errors at config-parse time.\n- **Glob entries reference secret variables only, never other env groups.** Inside a `foo/*` env, entries describe secret paths (optionally with `\\$1` captures) — they do not pull in other env labels.\n- **Wildcards produce nested output, never multiple files.** `foo/*` expanded over concrete children `foo/bar`, `foo/bar/baz` yields a single tree:\n ```\n foo:\n bar:\n baz: \u003cvalue\u003e\n ```\n This mirrors how sops would naturally represent a hierarchical namespace.\n\nThese constraints must be enforced by the label grammar validator and reflected in the resolver's output shape (which is a tree, not a flat list — see hm-20e).","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:38:39Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T20:51:37Z","closed_at":"2026-04-21T20:51:37Z","close_reason":"Schema + validator landed: validate_env_label, parse_captures, validate_envs, ProjectConfig::validate. 15 new tests green, full workspace builds clean. Mid-path wildcards, bare-wildcard, bad chars, capture-in-concrete-env, and high-capture-index all rejected. Resolver (hm-20e) and codegen (hm-ic2) can now consume these helpers.","dependencies":[{"issue_id":"hm-5v4","depends_on_id":"hm-r2e","type":"parent","created_at":"2026-04-21T13:39:43Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} -{"_type":"issue","id":"hm-r2e","title":"Preset environment management (TUI tab + codegen integration)","description":"## Motivation\n\nUsers already declare environment presets in config under `envs:` as a `BTreeMap\u003cString, Vec\u003cEnvEntry\u003e\u003e` with `Single`, `Glob`, and `Alias` variants (see rust/src/config/mod.rs:116, :134). Today this config is only consumable by read paths — there is no UX for authoring, visualizing, or sharing envs, and `himitsu codegen` currently emits language bindings rather than deployable secret bundles.\n\nThis epic adds a first-class preset environment manager that:\n- Surfaces envs as a dedicated tab in the TUI\n- Treats env labels as a path namespace: `foo`, `foo/bar`, `foo/*` are all valid, where `foo/*` is a wildcard shortcut that expands across children\n- Supports capture-group references inside wildcard entries, e.g. `/$1/postgres-url` lets `foo/*` expand into `foo/dev/postgres-url`, `foo/prod/postgres-url`, etc.\n- Mutates the correct config file based on context (project `.himitsu.yaml` vs global config)\n- Mirrors the config into the SQLite cache so reads are fast and the TUI stays responsive\n- Extends `himitsu codegen \u003cenv\u003e` to emit `\u003cenv\u003e.sops.yaml` — a valid SOPS-encrypted secret bundle — so envs become deployable artifacts, not just labels\n\n## Data model\n\nConceptually this is a many-to-many relationship: envs ↔ secrets. In SQLite this is cached as (example, subject to design child):\n\n- `envs(id, label, scope)` — `scope` ∈ {project, global}; `label` stores the full namespaced path (`foo`, `foo/bar`)\n- `env_entries(env_id, kind, value, alias_key, capture_pattern)` — one row per EnvEntry, preserving Single/Glob/Alias semantics\n\nAuthoritative storage stays YAML; SQLite is a rebuilt cache keyed off config file mtime/hash.\n\n## Scope boundary\n\n- Scope (project vs global) is inferred the same way `codegen` already does it — walk up for `.himitsu.yaml`, else global.\n- All mutations are writes to the YAML config plus a cache-refresh; no new storage of truth in SQLite.\n\n## Out of scope\n\n- Changing how secrets themselves are stored/encrypted\n- Re-keying or rotation flows (separate surface)\n- Any non-SOPS codegen output format (typescript/go/etc. stay as-is)\n\n## Success criteria\n\n- TUI has an Envs tab that lists, creates, edits, and deletes env presets with wildcard + alias support\n- Wildcard capture references (`$1`, `$2`, ...) resolve correctly in both TUI preview and codegen\n- `himitsu codegen \u003cenv\u003e` produces a `\u003cenv\u003e.sops.yaml` that round-trips through `sops` without manual editing\n- SQLite cache stays consistent with YAML after every mutation; restart reflects on-disk state","status":"closed","priority":2,"issue_type":"epic","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:38:26Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T21:57:31Z","closed_at":"2026-04-21T21:57:31Z","close_reason":"Read+delete TUI tab landed. Epic complete: schema + cache + resolver + mutation + codegen sops + TUI all merged. 5 new view tests; 337 lib tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-vy9","title":"remove reference to legacy 'groups' command - use recipients in a path-based manner instead, e.g. team/person or team/*","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:26:12Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T20:34:51Z","closed_at":"2026-04-21T20:34:51Z","close_reason":"removed group references from README and CLI help, updated to path-based recipients","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-czj","title":"TUI details/edit should support arbitrary fields (key/value pairs)","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-18T19:31:36Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T19:41:28Z","closed_at":"2026-04-18T19:41:28Z","close_reason":"annotations wired through decode/encode, TUI viewer displays them, edit doc accepts custom key:value as annotations","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-tz3","title":"TUI should display status of remote store (if its out of sync/unhealthy)","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:53:59Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T16:26:20Z","closed_at":"2026-04-18T16:26:20Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-6mo","title":"get rid of groups. recipients should allow for path-based names which would allow the equivalent of groups by just using foo/*","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:53:12Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T16:26:21Z","closed_at":"2026-04-18T16:26:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-6nb","title":"add export command that lets you create SOPS-compatble files, mapping multiple keys to secrets, with support for foo/bar/* which would include all matching secrets with their default key","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:51:13Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T16:26:21Z","closed_at":"2026-04-18T16:26:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-jk1","title":"add support for SOPS to import","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:49:37Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T16:26:21Z","closed_at":"2026-04-18T16:26:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-far","title":"import command should allow importing the whole vault, including a dry run","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:49:05Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T16:26:21Z","closed_at":"2026-04-18T16:26:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-yxb","title":"search should show time ago format for created","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:38:47Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T15:54:48Z","closed_at":"2026-04-18T15:54:48Z","close_reason":"Implemented in TUI search view: humanized dates via humanize_age() and description column added","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-zzu","title":"search page should show excerpt of description.","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:38:26Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T15:54:48Z","closed_at":"2026-04-18T15:54:48Z","close_reason":"Implemented in TUI search view: humanized dates via humanize_age() and description column added","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-pmy","title":"every mutation should trigger a git sync to remote if configured","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:23:14Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T16:26:21Z","closed_at":"2026-04-18T16:26:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-bln","title":"himitsu 'get/read' completions should show available secrets, not files in the current directory","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T14:11:07Z","created_by":"Cooper Maruyama","updated_at":"2026-04-17T07:14:55Z","closed_at":"2026-04-17T07:14:55Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-p8g","title":"add import command, start with import from 1password via op cli","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T14:09:11Z","created_by":"Cooper Maruyama","updated_at":"2026-04-17T07:14:57Z","closed_at":"2026-04-17T07:14:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-t81","title":"use 'n days ago' format for date in search. include description, and put store as last column.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T14:08:28Z","created_by":"Cooper Maruyama","updated_at":"2026-04-17T07:14:58Z","closed_at":"2026-04-17T07:14:58Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-f6z","title":"TUI: integration test harness driving App via simulated key events","description":"Build a test harness that constructs App, feeds a sequence of crossterm::event::KeyEvent through the main loop tick, and asserts on rendered buffer state. Enables end-to-end coverage of search→viewer→new-secret flows without spawning a real terminal.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T06:53:32Z","created_by":"Cooper Maruyama","updated_at":"2026-04-15T07:01:04Z","closed_at":"2026-04-15T07:01:04Z","close_reason":"Merged via parallel worktree agents; cargo test --bin himitsu tui::harness:: → 3 passed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-3cm","title":"TUI: configurable keybindings via config file","description":"Allow users to remap TUI key bindings through the himitsu config file. Define a KeyMap struct, default to current bindings, let config override by action name (e.g. 'quit', 'new_secret', 'switch_store'). Threaded through App to each view's handle_key.","status":"closed","priority":2,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T06:53:30Z","created_by":"Cooper Maruyama","updated_at":"2026-04-15T07:19:59Z","closed_at":"2026-04-15T07:19:59Z","close_reason":"Merged to main; 231 tests pass (toast + configurable keymap). Conflicts in app.rs/search.rs/secret_viewer.rs resolved by keeping toast-based action returns on top of keymap dispatch","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-o15","title":"TUI: status-line toast for transient feedback","description":"Add a toast/status-line component that renders transient messages (saved, copied, deleted, error) for N seconds then clears. Currently feedback is inline-only in individual views. Should be globally mounted in App so every view can emit a toast.","status":"closed","priority":2,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T06:53:21Z","created_by":"Cooper Maruyama","updated_at":"2026-04-15T07:19:59Z","closed_at":"2026-04-15T07:19:59Z","close_reason":"Merged to main; 231 tests pass (toast + configurable keymap). Conflicts in app.rs/search.rs/secret_viewer.rs resolved by keeping toast-based action returns on top of keymap dispatch","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-64c","title":"record new demo after major tui changes","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T02:28:44Z","created_by":"Cooper Maruyama","updated_at":"2026-04-15T02:54:24Z","closed_at":"2026-04-15T02:54:24Z","close_reason":"Rewrote and re-recorded all 6 TUI tapes (us-008..us-013) against search-as-root + new-secret metadata + store picker flow","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-r4i","title":"new secret flow should alow setting metadata, not just edit","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-14T14:23:45Z","created_by":"Cooper Maruyama","updated_at":"2026-04-15T02:46:54Z","closed_at":"2026-04-15T02:46:54Z","close_reason":"metadata fields (description/url/totp/env_key/expires_at) added to new-secret form with tab nav, validation, 13 new tests","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-y6n","title":"provide alternative hotkey for ctrl+s to save, as it is commmonly set in tmux and conflicts","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-14T14:23:19Z","created_by":"Cooper Maruyama","updated_at":"2026-04-14T14:28:32Z","closed_at":"2026-04-14T14:28:32Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-72k","title":"get rid of 'env', its being used as a group which is incorrect. it shouldnt even show in the search tui. env is for codegen which I will spec out later","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-14T14:20:35Z","created_by":"Cooper Maruyama","updated_at":"2026-04-14T14:28:32Z","closed_at":"2026-04-14T14:28:32Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-8z8","title":"search view should have a hotkey for copying to clipboard","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T18:07:09Z","created_by":"Cooper Maruyama","updated_at":"2026-04-14T14:23:23Z","closed_at":"2026-04-14T14:23:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-57k","title":"tui should render all stores together, rendering the store as a column","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T18:06:03Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T18:11:43Z","closed_at":"2026-04-13T18:11:43Z","close_reason":"multi-store grouping with per-store header rows","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-rnx","title":"TUI secret viewer should render all decoded metadata","description":"The secret viewer in the TUI only shows path/env/created_at/lastmodified/recipients. It should also render description, url, totp, expires_at, and env_key from the decoded SecretValue so users can see everything they just edited without round-tripping through the editor.","status":"closed","priority":2,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T18:05:44Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T18:12:51Z","closed_at":"2026-04-13T18:12:51Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-ldq","title":"Add env_key metadata field to SecretValue","description":"Prepare for 'himitsu exec path/* -- cmd' by letting each secret carry a default environment variable name. When unset, callers derive one from the path (typically last segment uppercased). Description field already exists from hm-dvs/hm-de4.","acceptance_criteria":"proto field added; --env-key flag on set; validates as POSIX env var name; surfaced by himitsu get; round-trips through encode/decode","status":"closed","priority":2,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T17:20:50Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T17:21:05Z","closed_at":"2026-04-13T17:21:05Z","close_reason":"env_key field added end-to-end with validation + tests","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-tcp","title":"columns should have header labels in tui","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T17:17:56Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T18:12:51Z","closed_at":"2026-04-13T18:12:51Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-3ir","title":"the default tui should use a search bar with fuzzy find instead of the two-column navigator","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T04:04:48Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T04:11:38Z","closed_at":"2026-04-13T04:11:38Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-jjc","title":"add fuzzy find to search","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T03:14:50Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-yu4","title":"recipients should allow for adding metadata. adding a recipient should additionally prompt for a description (optional)","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T03:11:02Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-dvs","title":"add a field called totp that can be used to associate a TOTP key for any secret. also add a URL field that can be used to store the website it is associated with","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T03:10:15Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-de4","title":"add a expires_at field to metadata, can be optionally set to remind when the secret is about to expire","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T03:08:26Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-6xd","title":".himitsu/recipients should contain a flat list of recipients. the group should not be determined by folder nesting, but rather by a mapping in .himitsu.yaml - this solves the issue where a recipient cannot belong to multiple groups without duplicating files","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T03:06:39Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-ftf","title":"implement himitsu read/write which are like get/set except they return just the plaintext secret, render no metadata, and are meant to be used in other tools","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T03:04:41Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-381","title":"implement completions","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T03:02:56Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-28r","title":"himitsu search should look similar to gh repo list","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:56:40Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-iq6","title":"himitsu tui navigation should look more like a table that shows the path, description, last modified date, and store it belongs to. folders should be sorted first just like in the file explorer in most IDEs","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:40:26Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:51:25Z","closed_at":"2026-04-13T03:51:25Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-6vb.6","title":"US-013: Store/env switch from dashboard","description":"Let the user switch the active store (recipients/data dir) without restarting the TUI. Add a store picker (e.g. 's' key) that lists configured stores from config and rebuilds the dashboard against the chosen store. Scope of 'env switch' is unclear — envs are derived from path prefixes, so selection already works; confirm with user whether this bead should also cover a true multi-context switch.","notes":"Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape → regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:14:51Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:12:16Z","closed_at":"2026-04-13T03:12:16Z","close_reason":"Closed","dependencies":[{"issue_id":"hm-6vb.6","depends_on_id":"hm-6vb","type":"parent-child","created_at":"2026-04-12T19:14:50Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-6vb.3","title":"US-011: Drill into secret from dashboard","description":"Pressing Enter on a secret in the dashboard's right-hand list currently does nothing. Wire it to open the SecretViewerView directly, skipping the search detour. Also add j/k/arrow navigation within the secret list (currently only env list navigates).","notes":"Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape → regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:14:40Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:12:15Z","closed_at":"2026-04-13T03:12:15Z","close_reason":"Closed","dependencies":[{"issue_id":"hm-6vb.3","depends_on_id":"hm-6vb","type":"parent-child","created_at":"2026-04-12T19:14:39Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-6vb.4","title":"US-012: Help overlay ('?')","description":"Add a modal help overlay bound to '?' (and maybe F1) that lists all keybindings for the current view. Should be view-aware: dashboard, search, and viewer each show their own bindings. Dismiss with Esc or '?'.","notes":"Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape → regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:14:40Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:12:15Z","closed_at":"2026-04-13T03:12:15Z","close_reason":"Closed","dependencies":[{"issue_id":"hm-6vb.4","depends_on_id":"hm-6vb","type":"parent-child","created_at":"2026-04-12T19:14:39Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-6vb.5","title":"US-008: Create secret from TUI","description":"Add a 'new secret' flow reachable from the dashboard (e.g. 'n' keybinding). Should prompt for env/path and value (value input hidden), then call the same internal path as 'himitsu set' — no subprocess. On success, refresh the dashboard list and select the new entry.","notes":"Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape → regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:14:40Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:12:15Z","closed_at":"2026-04-13T03:12:15Z","close_reason":"Closed","dependencies":[{"issue_id":"hm-6vb.5","depends_on_id":"hm-6vb","type":"parent-child","created_at":"2026-04-12T19:14:39Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-6vb.1","title":"US-009: Delete secret from TUI","description":"Add a delete action in the secret viewer and/or search result list (e.g. 'd' with a confirmation prompt). Wire to the existing store delete path. Refresh the parent view on success and surface errors as a status line.","notes":"Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape → regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:14:39Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:12:15Z","closed_at":"2026-04-13T03:12:15Z","close_reason":"Closed","dependencies":[{"issue_id":"hm-6vb.1","depends_on_id":"hm-6vb","type":"parent-child","created_at":"2026-04-12T19:14:39Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-6vb.2","title":"US-010: Edit secret value from TUI","description":"Currently 'e' on the secret viewer rekeys — rebind rekey and add a real edit flow that decrypts the current value, opens it in an in-TUI editor (or $EDITOR fallback with terminal suspend, matching the init wizard pattern), and re-encrypts on save. Decide with the user whether to preserve 'e' for edit and move rekey to a less prominent key.","notes":"Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape → regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:14:39Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:12:15Z","closed_at":"2026-04-13T03:12:15Z","close_reason":"Closed","dependencies":[{"issue_id":"hm-6vb.2","depends_on_id":"hm-6vb","type":"parent-child","created_at":"2026-04-12T19:14:39Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-6vb","title":"TUI Phase 2: CRUD + UX polish","description":"Follow-on epic to hm-nck. The Rust/ratatui TUI shipped as a read-only explorer (dashboard → search → viewer). This epic adds the missing CRUD actions (create/edit/delete), first-class navigation from the dashboard, a help overlay, and store/env switching so the TUI stands on its own without shelling out to the CLI for common operations.","status":"closed","priority":2,"issue_type":"epic","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:14:19Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:12:16Z","closed_at":"2026-04-13T03:12:16Z","close_reason":"all steps complete","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-c84","title":"US-007: Global navigation and keybindings","description":"Consistent global keybindings: q quits from dashboard (ignored in inputs), Ctrl-C always quits with terminal cleanup, Esc goes back one view, / enters search from dashboard. Footer hint bar on every view. Resize event re-lays out without panic. Event loop uses crossterm::event::poll with sane timeout (no busy spin).","acceptance_criteria":"q/Ctrl-C/Esc/'/' bindings wired; footer hint on each view; resize handled; poll-based event loop","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:14:48Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T02:10:40Z","closed_at":"2026-04-13T02:10:40Z","close_reason":"Global nav + keybindings wired via DashboardAction router in 02af8ab. q/Ctrl-C/Esc/'/' bindings live; footer on every view; poll-based loop with 100ms timeout; resize auto-re-lays out via ratatui frame draw each tick.","dependencies":[{"issue_id":"hm-c84","depends_on_id":"hm-djm","type":"blocks","created_at":"2026-04-12T18:15:19Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-c84","depends_on_id":"hm-nck","type":"parent-child","created_at":"2026-04-12T18:15:13Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-xe5","title":"US-006: Secret viewer","description":"Show path, env, recipients, created_at, lastmodified. Value hidden by default; 'r' reveals via existing get code path. 'y' copies to clipboard via arboard (graceful no-op if unavailable). 'e' triggers rekey for the current secret. Esc returns to previous view.","acceptance_criteria":"metadata shown; r reveals value; y copies via arboard; e triggers rekey; esc returns","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:14:39Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T02:09:54Z","closed_at":"2026-04-13T02:09:54Z","close_reason":"Implemented SecretViewerView with reveal/copy/rekey/esc, wired into router via SearchAction::OpenViewer; 6 tests passing","dependencies":[{"issue_id":"hm-xe5","depends_on_id":"hm-nck","type":"parent-child","created_at":"2026-04-12T18:15:13Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-xe5","depends_on_id":"hm-rd2","type":"blocks","created_at":"2026-04-12T18:15:20Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-pp9","title":"US-005: Search view","description":"/ from dashboard opens search with focused text input. Results update as query changes. Uses extracted core of crate::cli::search (refactor search::run to split IO from logic). Results show store, path, created_at. Enter on a result navigates to secret viewer. Esc returns to dashboard.","acceptance_criteria":"search view opens on /; live results; uses extracted search core; Enter opens viewer; Esc returns to dashboard","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:14:32Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T02:09:46Z","closed_at":"2026-04-13T02:09:46Z","close_reason":"Landed in 02af8ab","dependencies":[{"issue_id":"hm-pp9","depends_on_id":"hm-nck","type":"parent-child","created_at":"2026-04-12T18:15:12Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-pp9","depends_on_id":"hm-rd2","type":"blocks","created_at":"2026-04-12T18:15:19Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-rd2","title":"US-004: Dashboard view (envs + secrets)","description":"Two-pane layout: left = environments list, right = secrets for selected env. Header shows active store slug and env count. Arrow keys / j/k navigate envs. Right pane updates live on selection change. Data via internal Rust APIs (ls equivalent), not subprocess. Empty state message when no envs. Footer lists keybindings.","acceptance_criteria":"two-pane layout; live right-pane updates; uses internal APIs not subprocess; empty state handled; footer keybindings shown","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:14:25Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T01:52:00Z","closed_at":"2026-04-13T01:52:00Z","close_reason":"US-003 init wizard + US-004 dashboard view landed; DashboardView ported to ratatui with envs/secrets two-pane layout, run_init_flow event loop wired.","dependencies":[{"issue_id":"hm-rd2","depends_on_id":"hm-djm","type":"blocks","created_at":"2026-04-12T18:15:19Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-rd2","depends_on_id":"hm-nck","type":"parent-child","created_at":"2026-04-12T18:15:12Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} -{"_type":"issue","id":"hm-b49","title":"US-003: Init wizard view (ratatui)","description":"Port the 3-step init wizard to ratatui: Step 1 data dir text input pre-filled from config::data_dir(); Step 2 remote slug validated with config::validate_remote_slug; Step 3 key provider select (hidden if macOS Keychain unavailable). Enter advances, Esc back, Ctrl-C aborts. On confirm calls init::run_init directly (not subprocess). Success screen shows pubkey and advances to dashboard. Replaces init::run_wizard cliclack path when TTY and no --json/--no-tui.","acceptance_criteria":"three steps rendered; validation wired; calls init::run_init directly; cliclack path removed for TTY case; success screen advances to dashboard","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:14:18Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T01:52:37Z","closed_at":"2026-04-13T01:52:37Z","close_reason":"ratatui init wizard landed: 3-step flow (data dir/remote slug/key provider), validation wired, calls init::run_init directly, cliclack TTY path removed, success advances to dashboard","dependencies":[{"issue_id":"hm-b49","depends_on_id":"hm-djm","type":"blocks","created_at":"2026-04-12T18:15:18Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-b49","depends_on_id":"hm-nck","type":"parent-child","created_at":"2026-04-12T18:15:11Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-4y7","title":"US-002: Remove legacy Bun/OpenTUI TUI","description":"Delete the entire tui/ directory and remove himitsuTUI derivation + bun2nix input from flake.nix. Remove any himitsu-tui references from nativeBuildInputs and the HIMITSU_BIN env forwarding from launch_tui().","acceptance_criteria":"tui/ directory deleted; flake.nix has no himitsuTUI/bun2nix refs; HIMITSU_BIN forwarding removed; nix build still succeeds","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:14:08Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T01:43:39Z","closed_at":"2026-04-13T01:43:39Z","close_reason":"Deleted tui/, dropped bun2nix input, removed himitsuTUI deriv. nix flake check + cargo test/clippy/fmt all green.","dependencies":[{"issue_id":"hm-4y7","depends_on_id":"hm-djm","type":"blocks","created_at":"2026-04-12T18:15:18Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-4y7","depends_on_id":"hm-nck","type":"parent-child","created_at":"2026-04-12T18:15:11Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-djm","title":"US-001: Scaffold ratatui module and wire up entry point","description":"Add ratatui/crossterm to rust/Cargo.toml and create rust/src/tui/ with mod.rs, app.rs, event.rs. Public entry himitsu::tui::run() installs/tears down raw mode + alternate screen, runs a no-op event loop that exits on q or Ctrl-C. Cli::launch_tui() in rust/src/cli/mod.rs calls tui::run() directly instead of spawning himitsu-tui. Terminal state restored on panic via drop guard or set_hook.","acceptance_criteria":"ratatui+crossterm in Cargo.toml; rust/src/tui/{mod,app,event}.rs created; himitsu::tui::run() exists; launch_tui calls it in-process; raw mode restored on panic","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:14:01Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T01:17:48Z","closed_at":"2026-04-13T01:17:48Z","close_reason":"Closed","dependencies":[{"issue_id":"hm-djm","depends_on_id":"hm-nck","type":"parent-child","created_at":"2026-04-12T18:15:11Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":4,"comment_count":0} -{"_type":"issue","id":"hm-nck","title":"Replace OpenTUI TUI with Rust/ratatui","description":"Replace the Bun + @opentui/core TUI (in tui/, launched as a subprocess via himitsu-tui) with a pure-Rust TUI built on ratatui + crossterm, living inside the existing himitsu binary at rust/src/tui/. Running 'himitsu' with no subcommand will launch the new TUI in-process, eliminating the subprocess hop, the HIMITSU_BIN fallback, and the entire Bun toolchain from the build. Full PRD in commit history / conversation.","status":"closed","priority":2,"issue_type":"epic","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:13:45Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T02:10:40Z","closed_at":"2026-04-13T02:10:40Z","close_reason":"all steps complete","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-xx0","title":"himitsu 'add' should behave like an alias for himitsu 'set'","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-12T23:51:12Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T01:17:38Z","closed_at":"2026-04-13T01:17:38Z","close_reason":"Added clap alias='add' to Set command in rust/src/cli/mod.rs","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-3w1","title":"running 'himitsu' with no args should launch the tui","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-12T23:50:49Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T01:17:38Z","closed_at":"2026-04-13T01:17:38Z","close_reason":"Already implemented — cdf8757 feat: launch TUI when himitsu is run with no subcommand","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-457","title":"running 'himitsu' with no args should launch the tui","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-12T13:39:46Z","created_by":"Cooper Maruyama","updated_at":"2026-04-12T13:47:48Z","closed_at":"2026-04-12T13:47:48Z","close_reason":"Implemented: running himitsu with no args now launches the TUI (himitsu-tui binary). Falls back to a helpful error if TUI not installed.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-anf","title":"himitsu 'add' should behave like an alias for himitsu 'set'","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-12T13:38:51Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T01:17:38Z","closed_at":"2026-04-13T01:17:38Z","close_reason":"Added clap alias='add' to Set command in rust/src/cli/mod.rs","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-3us","title":"Document external import/export requirements","description":"Add README notes for external tool requirements used by import/export/generate flows, including op for 1Password and sops for encrypted YAML/JSON output.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:51:24Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:01:09Z","started_at":"2026-05-03T21:59:16Z","closed_at":"2026-05-03T22:01:09Z","close_reason":"Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync \u0026 Store Health section (auto-commit/push, --no-push, store health states), new Demo \u0026 Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-u7g","title":"Add README demo recording instructions","description":"Add a Demo or Recording section with the canonical local command to regenerate the VHS demo: cargo build --release \u0026\u0026 vhs demo/demo.tape, plus CI scratch-output behavior.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:51:24Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:01:09Z","started_at":"2026-05-03T21:59:16Z","closed_at":"2026-05-03T22:01:09Z","close_reason":"Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync \u0026 Store Health section (auto-commit/push, --no-push, store health states), new Demo \u0026 Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-9lg","title":"Clarify README demo GIF and tape roles","description":"Update README demo references to use demo/demo-vhs.gif as the canonical polished demo and explain demo/tui-us-* tapes as per-story regression/demo artifacts.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:51:23Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:01:09Z","started_at":"2026-05-03T21:59:17Z","closed_at":"2026-05-03T22:01:09Z","close_reason":"Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync \u0026 Store Health section (auto-commit/push, --no-push, store health states), new Demo \u0026 Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-cfo","title":"Distribute TUI footer actions evenly","description":"Render footer action groups in evenly distributed segments instead of left/right weighted alignment.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T11:54:15Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T11:56:36Z","started_at":"2026-04-30T11:54:26Z","closed_at":"2026-04-30T11:56:36Z","close_reason":"Added shared distributed footer renderer and applied it to search and envs footers","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-7q6","title":"Align TUI footer right actions","description":"Use a split footer layout so the right-most footer actions render flush right instead of relying on spacer text.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T10:57:00Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T10:58:24Z","started_at":"2026-04-30T10:57:22Z","closed_at":"2026-04-30T10:58:24Z","close_reason":"Updated envs footer to split left actions from right-aligned help/back actions using a horizontal layout","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-cpf","title":"TUI envs editor supports multi-entry envs","description":"The envs edit flow currently supports single-entry env labels and returns an error for labels with multiple EnvEntry rows. Extend the editor state to hold multiple entry rows with add/remove controls so existing multi-entry envs can be edited in place.","status":"closed","priority":3,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T10:45:06Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T23:23:27Z","started_at":"2026-04-30T23:23:04Z","closed_at":"2026-04-30T23:23:27Z","close_reason":"Superseded by hm-6qj envs revamp, which redesigns the editor with full multi-entry/glob support from the ground up.","dependencies":[{"issue_id":"hm-cpf","depends_on_id":"hm-h9m","type":"discovered-from","created_at":"2026-04-30T03:45:06Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-h9m","title":"TUI envs tab: edit existing env with live capture preview","description":"Follow-up to hm-zzj. v1 ships read + delete; this adds `e` to edit an existing env's entries.\n\n## UX\n\n- `e` opens the same editor as the create flow (file separately as hm-zzj-followup-create) prefilled with the selected env's current entries.\n- Right pane continues to show the live `env_resolver::resolve` output as the user edits, so capture-ref expansions (`/$1/postgres-url` against actual store secrets) update in real time.\n- Save calls `envs_mut::upsert` (which is upsert semantics — replaces existing rows).\n\n## Notes\n\n- Depends on the create flow (shares the editor widget). Build the editor once, reuse it.\n- For wildcard envs, the live preview must enumerate `available_secrets` via `crate::remote::store::list_secrets` to compute capture matches.","status":"closed","priority":3,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-21T21:58:07Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T10:45:15Z","started_at":"2026-04-30T10:43:17Z","closed_at":"2026-04-30T10:45:15Z","close_reason":"Added env edit flow with prefilled editor, Ctrl-S upsert save, live preview while editing, and single-entry edit tests; filed hm-cpf for multi-entry editing","dependencies":[{"issue_id":"hm-h9m","depends_on_id":"hm-udz","type":"blocks","created_at":"2026-04-21T14:58:17Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-udz","title":"TUI envs tab: create new env flow","description":"Follow-up to hm-zzj. v1 of the envs tab shipped read + delete only. This adds the `n` flow to create a new env from inside the tab.\n\n## UX\n\n- `n` opens an inline editor split with two fields:\n 1. Label input — validated live against `validate_env_label`. Show grammar hints (concrete vs `\u003cprefix\u003e/*`).\n 2. Entries list — repeatable rows, each picking a kind (Single | Glob | Alias) and the path/key fields.\n- Save (`Ctrl+S` or `Enter` on a \"Save\" button) calls `envs_mut::upsert(label, entries, scope_hint, cwd)`; on success, reload and select the new label. On error, toast the message and keep the editor open.\n- `Esc` cancels with a confirmation if the form is dirty.\n\n## Wiring\n\n- Route through the same `View::Envs` slot or add a sub-modal — your call when implementing.\n- Reuse the keymap binding `keymap.envs` already defined; add a sub-binding under it for `n`.","status":"closed","priority":3,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-21T21:58:07Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T10:41:14Z","started_at":"2026-04-30T10:37:58Z","closed_at":"2026-04-30T10:41:14Z","close_reason":"Added TUI envs new-env flow with inline editor, live validation, save via envs_mut::upsert, toasts, and tests","dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"hm-79o","title":"Docs: update README for search-as-root TUI flow","description":"README still references the dashboard-root flow. Update screenshots/gifs to match search-as-root, refresh the key-binding table to list current shortcuts (Ctrl+N new, Ctrl+S store, Ctrl+Y copy, ? help, Esc quit, e edit, d delete with y/n confirm).","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T06:53:34Z","created_by":"Cooper Maruyama","updated_at":"2026-04-15T07:01:04Z","closed_at":"2026-04-15T07:01:04Z","close_reason":"Merged via parallel worktree agents; cargo test --bin himitsu tui::harness:: → 3 passed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hm-6ia","title":"CI: run vhs against demo tapes to catch TUI flow regressions","description":"Add a GitHub Actions job that installs vhs and runs the us-008..us-013 tapes (non-interactive, no gif diffing needed — just exit code) so broken tapes fail CI before they land. Cache cargo build, reuse release binary.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T06:53:33Z","created_by":"Cooper Maruyama","updated_at":"2026-04-15T07:01:04Z","closed_at":"2026-04-15T07:01:04Z","close_reason":"Merged via parallel worktree agents; cargo test --bin himitsu tui::harness:: → 3 passed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type": "issue", "id": "hm-68a", "title": "introduce an element that is to be used anywhere where i am typing a reference to a secret. it should autocomplete as i type, showing me the closest match based on levenshtein distance. the same code should be used to also add a 'did you mean X' when himitsu search returns no results", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-05-07T06:22:32Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-09T02:11:54Z", "dependency_count": 0, "dependent_count": 0, "comment_count": 0, "closed_at": "2026-05-09T02:11:54Z", "close_reason": "Implemented Levenshtein-based 'did you mean' for himitsu search and a reusable TUI autocomplete popup wired into the search view (Ctrl+Space).", "started_at": "2026-05-07T06:22:32Z"} +{"_type": "issue", "id": "hm-7ob", "title": "Update command palette layout", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-05-07T06:06:18Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-07T06:10:27Z", "started_at": "2026-05-07T06:06:18Z", "closed_at": "2026-05-07T06:10:27Z", "close_reason": "Updated command palette layout and copy", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-isi", "title": "add support for TUI hints - a small bit of text that can render in subtle text on the bottom left corner, sort of like a floating element", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-05-06T23:21:58Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-09T02:11:54Z", "dependency_count": 0, "dependent_count": 0, "comment_count": 0, "closed_at": "2026-05-09T02:11:54Z", "close_reason": "Added bottom-left ambient hint surface; new-secret form publishes step-aware tips for path/tags/expires_at.", "started_at": "2026-05-06T23:21:58Z"} +{"_type": "issue", "id": "hm-3w5", "title": "all form fields should have placeholders containing an example", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-05-06T23:20:58Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-09T02:11:54Z", "dependency_count": 0, "dependent_count": 0, "comment_count": 0, "closed_at": "2026-05-09T02:11:54Z", "close_reason": "All new-secret form fields (and the generic FormView) now render muted placeholder examples when empty + unfocused.", "started_at": "2026-05-06T23:20:58Z"} +{"_type": "issue", "id": "hm-3rr", "title": "add a submit button to the new secret tui, and add a confirm dialog when clicking esc that lets you save or discard", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-05-06T23:20:25Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-09T02:11:54Z", "dependency_count": 0, "dependent_count": 0, "comment_count": 0, "closed_at": "2026-05-09T02:11:54Z", "close_reason": "Added a final [ submit ] tab stop and an unsaved-changes confirm dialog that fires on Esc with any populated field.", "started_at": "2026-05-06T23:20:25Z"} +{"_type": "issue", "id": "hm-6if", "title": "Add tags support for secrets (search + env composition)", "description": "## Motivation\n\nToday a secret's only addressable axis is its path. That works for hierarchies (`prod/api/STRIPE_KEY`) but not for crosscutting groupings \u2014 \"everything PCI\", \"everything the mobile team owns\", \"rotate-this-quarter\". Users currently fake it with path conventions, which collide with the env-prefix model.\n\nAdd free-form tags to secrets so that:\n- An item can carry many tags (`[\"pci\", \"stripe\", \"mobile\"]`).\n- `himitsu search` and `himitsu ls` can filter by tag.\n- The env DSL can select by tag, so an env like `pci-prod` can be authored as `tag: pci` instead of enumerating paths.\n\n## Data model\n\nTags live on `SecretValue` (the encrypted payload), as `repeated string tags = 9` in `proto/secrets.proto`. They travel with the value, are written/read through the existing age envelope, and stay atomic with the secret they describe.\n\n`SecretEntry.tags` (already declared at `proto/secrets.proto:89`) is repurposed as the manifest projection of the same field \u2014 populated by listing code that decrypts to surface metadata, identical to how descriptions are currently surfaced in `cli::search::search_core`.\n\nBackwards compatibility: empty tags is the default; legacy envelopes round-trip through `crypto::secret_value::decode` unchanged.\n\nTag grammar: `[A-Za-z0-9_.-]+`, 1\u201364 chars, no whitespace, case-sensitive. Validated at write time.\n\n## Authoring surfaces\n\n### CLI\n- `himitsu set --tag --tag ...` \u2014 repeatable flag on `cli/set.rs`.\n- `himitsu write --tag ` \u2014 same flag wired through `cli/write.rs`.\n- `himitsu tag add ...` / `rm ...` / `list` \u2014 new subcommand at `rust/src/cli/tag.rs`, registered in `cli/mod.rs`. Mutates by decrypt \u2192 edit `tags` \u2192 re-encrypt \u2192 write.\n\n### TUI\n- `tui/views/new_secret.rs`: add a `Tags` step (comma-separated input, validated per char as the user types) between `Description` and `EnvKey`.\n- `tui/views/secret_viewer.rs`: render tags as chips in the metadata pane.\n- `tui/views/search.rs`: render tag chips next to each result row; clicking/pressing a chip refines the search.\n\n## Search & filter\n\n- `himitsu search [--tag t]...`: AND-semantics across tag flags; empty query + tags = \"all secrets with these tags\". Tags read off the same `Decoded` struct produced by `search_core` decrypt loop.\n- `himitsu ls --tag t [--tag t]...`: same AND filter, applied after path-prefix walk.\n- Query-string sugar (out of scope for this epic, file as follow-up): `tag:foo bar` syntax inside the freeform query.\n\n## Env DSL \u2014 new `Tag` entry\n\nExtend `EnvEntry` (`rust/src/config/mod.rs:191`) with a `Tag(String)` variant. YAML shapes the deserializer accepts (extending `EnvEntry::deserialize` at `mod.rs:215`):\n\n- Inline string with `tag:` prefix:\n ```yaml\n envs:\n pci-prod:\n - tag:pci\n - tag:prod\n ```\n AND-semantics across multiple `tag:` entries inside one env (consistent with CLI `--tag` repetition).\n\n- Map form for explicit alias-rename, parallel to existing alias:\n ```yaml\n envs:\n pci-prod:\n - { tag: pci } # all pci-tagged secrets, key = last path segment\n - { STRIPE: tag:stripe } # alias: env-key STRIPE := the (singular) stripe-tagged secret\n ```\n Map-form must error when more than one secret matches the tag (alias requires a single value).\n\nResolver (`rust/src/config/env_resolver.rs:63`) gains a parallel branch to `resolve_concrete` / `resolve_wildcard`: walk `available_secrets`, decrypt-or-cache to read `tags`, include those that match. Wildcard capture refs (`$1`) are not extended to tag entries in this epic \u2014 out of scope.\n\n## Out of scope\n- Query-string `tag:` sugar inside `search ` (file as follow-up after this lands).\n- Sharing/inheriting tags across stores via policy.\n- Tag autocomplete in the TUI tag input (nice-to-have follow-up).\n- Capture-ref interpolation inside `Tag` env entries.\n- Renaming/merging tags as a first-class operation (do it manually via `tag rm` + `tag add`).\n\n## Success criteria\n- `himitsu set foo --tag a --tag b` round-trips: `himitsu get foo` shows tags in the metadata block, `himitsu tag foo list` prints `a\\nb`.\n- `himitsu search --tag a` returns only secrets carrying tag `a`; same for `ls --tag a`.\n- `himitsu codegen ` against an env with `- tag:a` produces a SOPS bundle whose keys are exactly the secrets tagged `a`.\n- TUI new-secret form accepts comma-separated tags; secret-viewer renders them; search rows show tag chips.\n- All existing tests pass; new tests cover: tag validation, `EnvEntry` (de)serialization round-trip for both string and map forms, resolver behavior with tag entries, `search`/`ls` filter logic.\n- Legacy secrets (no tags field) decode unchanged.\n", "status": "closed", "priority": 2, "issue_type": "epic", "owner": "noreply@anthropic.com", "created_at": "2026-05-05T12:14:38Z", "created_by": "Claude", "updated_at": "2026-05-09T02:11:54Z", "dependency_count": 0, "dependent_count": 0, "comment_count": 0, "closed_at": "2026-05-09T02:11:54Z", "close_reason": "Tags support already shipped end-to-end across CLI (set/write/search/ls/tag), config (EnvEntry::Tag, env resolver), and TUI (new-secret Tags step, viewer chips, search row chips). Closing the meta-epic.", "started_at": "2026-05-05T12:14:38Z"} +{"_type": "issue", "id": "hm-scm", "title": "instead of adding a row and indenting items that have a common prefix, add a key to fold/unfold by 1 level, get rid of the extra row, and use a sutble accent to show when folded/unfolded", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-04T12:25:10Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-05T03:57:37Z", "started_at": "2026-05-04T12:25:35Z", "closed_at": "2026-05-05T03:57:37Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-bs1", "title": "footer items are not spaced evenly - right-most item should be right-aligned", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-04T12:12:52Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-04T12:14:30Z", "started_at": "2026-05-04T12:13:19Z", "closed_at": "2026-05-04T12:14:30Z", "close_reason": "render_distributed_footer now anchors first item Left and last item Right so the footer row reads as evenly distributed across the full width", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-dsa", "title": "ctrl+P command menu should contain all valid commands from the stateless CLI", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-04T12:12:25Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-05T03:57:37Z", "started_at": "2026-05-04T12:25:22Z", "closed_at": "2026-05-05T03:57:37Z", "close_reason": "Ctrl+P palette now lists every visible top-level CLI command. Wired commands (new/sync/rekey/join/add-remote/switch-store/envs/help/quit/toggle-store-column) keep their behavior. New entries (recipient ls/add/rm/show, remote list/remove/default, context show/set/clear, generate, export, check, docs, import, git) emit an info toast naming the equivalent CLI invocation; full TUI forms can be wired per-command as follow-up.", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-8jm", "title": "fix himitsu sync failing in the current state", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-04T12:09:41Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-04T12:17:24Z", "started_at": "2026-05-04T12:11:24Z", "closed_at": "2026-05-04T12:17:24Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-mw9", "title": "add any compatible commands from the stateless CLI to the command picker in the TUI", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-04T01:15:29Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-04T01:18:27Z", "started_at": "2026-05-04T01:15:44Z", "closed_at": "2026-05-04T01:18:27Z", "close_reason": "Added sync, rekey, and join commands to the TUI command palette", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-1wl", "title": "himitsu import should convert SOME_KEY to some-key. it should also allow me to do a simple glob filter like foo/* and only import matches, and then it should also let me (unless it does already) set a prefix like --to some/target which would create some/target/{foo,bar,...} or --to github:org/repo#foo would put them in the foo directory. also, --to foo/bar should be equivalent to --to foo/bar/ (the trailing slash is ignored)", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-04T01:01:12Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-04T01:07:04Z", "started_at": "2026-05-04T01:01:36Z", "closed_at": "2026-05-04T01:07:04Z", "close_reason": "Implemented key normalization (SOME_KEY\u2192some-key), --filter glob, --to prefix, and --keep-names opt-out", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-u0v", "title": "auto-detect if recipient list doesn't contain own age key", "description": "## Context\n in that case, the sync status should glow orange and tell you to run 'himitsu join' which would simply add own public key to the stores recipient list, commit, and push. github actions should then rekey in a self-serve way if it is configured with a key in secrets. adding this key to secrets is also something that himitsu can do for you automatically. this task can be seen as idempotently ensuring those 3 conditions are met", "notes": "Scope per description: idempotently ensure (1) own pubkey is in the store's recipient list, (2) GitHub Actions has a configured age key in repo secrets, (3) auto-rekey via Actions is wired up. Surface state via TUI store-health glow + 'himitsu join' command that performs (1)-(3). Significant product/UX design \u2014 defer until owner aligns on join command shape and Actions self-serve flow.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-03T21:56:09Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:12:52Z", "started_at": "2026-05-03T22:06:52Z", "closed_at": "2026-05-03T22:12:52Z", "close_reason": "Implemented himitsu join command and TUI NotRecipient health indicator", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-bqk", "title": "himitsu isn't automatically running init when user has no store", "notes": "Investigation 2026-05-03: tui::run (rust/src/tui/mod.rs:33) auto-launches the wizard only on missing key (~/.local/share/himitsu/key). Gap: a user with a key but no resolved store gets an empty dashboard. Fix would be to also trigger run_init_flow when ctx.store.as_os_str().is_empty(). Risk: run_init_flow currently calls run() recursively after the wizard completes (mod.rs:126); init can complete-and-Success without creating a store if --name/--project are not used in the wizard, which would create an infinite loop. Either (a) require --name in the wizard's success path, (b) only re-enter run() when wizard reports a store was created, or (c) return Ok(()) from run() when store is still empty post-wizard. Needs interactive testing.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-03T21:52:55Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:07:19Z", "started_at": "2026-05-03T22:05:14Z", "closed_at": "2026-05-03T22:07:19Z", "close_reason": "TUI now launches init flow when key exists but no store resolves, with post-wizard no-store guard.", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-4y5", "title": "Cross-repo ref: prompt to clone missing referenced store", "description": "When a qualified cross-repo reference (github:org/repo#path) hits a store that isn't checked out locally, prompt for confirmation before cloning. Interactive: y/N prompt (default no). Non-interactive (no TTY): error with hint to run remote add. Add --yes to bypass confirmation for scripted use. TUI: small modal.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T10:55:51Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-01T10:57:55Z", "started_at": "2026-05-01T10:56:38Z", "closed_at": "2026-05-01T10:57:55Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-w6c", "title": "Cross-repo ref: prefer github:org/repo#path syntax (accept legacy / form)", "description": "Switch the canonical cross-repo reference syntax from github:org/repo/path/to/secret to github:org/repo#path/to/secret to disambiguate the slug from the path. Parser must accept both forms; the # form is the canonical one (used in docs, error messages, codegen). The slash form continues to parse as a fallback.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T10:55:51Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-01T10:57:55Z", "started_at": "2026-05-01T10:55:55Z", "closed_at": "2026-05-01T10:57:55Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-k4c", "title": "init should have placeholder for config set to project if in a git repo, unless the user has not configured a global store yet", "description": "In the init wizard's remote-store step, when inside a git repo (or parent is), default the placeholder to the current repo's origin slug and write to /himitsu.yaml as project-scoped config. Always run the global step first (skipped if already configured), labeled 'configure global store', then run a separate 'configure project' step (skipped if not in a git repo).", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T10:29:47Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-01T10:42:26Z", "closed_at": "2026-05-01T10:42:26Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-anm", "title": "Expand README sync and store health docs", "description": "Document sync behavior, auto-commit/push, no-remote and not-pushed warnings, --no-push, auto_pull, and TUI store health states.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:51:23Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:01:09Z", "started_at": "2026-05-03T21:59:14Z", "closed_at": "2026-05-03T22:01:09Z", "close_reason": "Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync & Store Health section (auto-commit/push, --no-push, store health states), new Demo & Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-1lt", "title": "Make README quick start TUI-first", "description": "Revise README Quick Start so the primary path launches the TUI first, then presents CLI commands as scripting/automation alternatives.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:51:22Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:01:09Z", "started_at": "2026-05-03T21:59:15Z", "closed_at": "2026-05-03T22:01:09Z", "close_reason": "Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync & Store Health section (auto-commit/push, --no-push, store health states), new Demo & Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-qku", "title": "Document TUI theme and key configuration", "description": "Add README documentation for global config TUI settings, including tui.theme, tui.keys, accepted built-in themes, and HIMITSU_TUI_THEME override.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:51:22Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:01:09Z", "started_at": "2026-05-03T21:59:15Z", "closed_at": "2026-05-03T22:01:09Z", "close_reason": "Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync & Store Health section (auto-commit/push, --no-push, store health states), new Demo & Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-o89", "title": "Fix README search documentation", "description": "Update README search feature and command docs to reflect current behavior: search reads store files directly and --refresh is retained as a no-op compatibility flag, not a SQLite index rebuild.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:51:21Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:01:09Z", "started_at": "2026-05-03T21:59:16Z", "closed_at": "2026-05-03T22:01:09Z", "close_reason": "Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync & Store Health section (auto-commit/push, --no-push, store health states), new Demo & Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-1b7", "title": "Review README and stabilize demo sync state", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:37:30Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-01T00:40:56Z", "started_at": "2026-05-01T00:37:37Z", "closed_at": "2026-05-01T00:40:56Z", "close_reason": "Reviewed README and added demo sync-state override", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-2f9", "title": "Refresh VHS demo tapes", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T23:45:18Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-01T00:03:22Z", "started_at": "2026-04-30T23:45:23Z", "closed_at": "2026-05-01T00:03:22Z", "close_reason": "Refreshed VHS demo as sourced pure TUI tape and validated render", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-wqb", "title": "allow editing name of items", "description": "In the himitsu TUI, pressing Enter on an item opens an editor that lets you edit every field EXCEPT the item's name. Make the name field editable too.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T23:19:04Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-09T02:11:54Z", "started_at": "2026-04-30T23:23:03Z", "dependency_count": 0, "dependent_count": 0, "comment_count": 0, "closed_at": "2026-05-09T02:11:54Z", "close_reason": "Editing the path field in the secret-viewer edit doc now renames the secret while preserving created_at + history (covered by finish_edit_renames_secret_and_preserves_history)."} +{"_type": "issue", "id": "hm-6qj", "title": "revamp envs feature.", "description": "## Context\n envs should ultimately be stored like this:\n# config.yaml\n...\nenvs:\n my-env:\n - SOME_KEY: some-exact-match # optionally override env key\n - other-exact-match # uses env key set on item or fallsback to upper(replace(name, -,_)) (forward slash replaced with __)\n - arbitrary-glob/*\nmy-env-{dev,prod,stg}:\n - SOME_KEY: ref-requires-{} # requires specifying where to replace\n - some-group/item-name-{} # using the {} is not required if you have a better format\n\n\nTherefore, the env editor should have an initial browser where i can select an existing env or add a new one, then take me into a 2-panel layout where I am editing the left side as if it was hx and the right pane shows me a preview of what env would be generated based on matches and/or any errors. there should be fuzzy find support and some auto-complete like feature that lets me discover the right labels as im editing", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T23:16:44Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-09T02:11:54Z", "started_at": "2026-04-30T23:23:04Z", "dependency_count": 0, "dependent_count": 0, "comment_count": 0, "closed_at": "2026-05-09T02:11:54Z", "close_reason": "envs feature revamp shipped: 2-pane DSL editor with live preview pane, fuzzy-find autocomplete corpus over secret names/groups, scope-aware browser, wildcard envs."} +{"_type": "issue", "id": "hm-5l4", "title": "Highlight self in recipient ls", "description": "Update himitsu recipient ls output so recipient rows whose full age public key matches the current user's own public key are highlighted in TTY output without changing non-TTY/plain output.", "status": "closed", "priority": 2, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T12:26:21Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T12:28:59Z", "started_at": "2026-04-30T12:26:43Z", "closed_at": "2026-04-30T12:28:59Z", "close_reason": "Highlighted recipient ls rows whose full public key matches the local self public key in TTY output, preserving plain non-TTY output", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-cak", "title": "Fix search footer alignment and centering", "description": "Apply split-layout footer alignment to the search view and correct small-screen search canvas centering.", "status": "closed", "priority": 2, "issue_type": "bug", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T11:01:04Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T11:02:44Z", "started_at": "2026-04-30T11:01:33Z", "closed_at": "2026-04-30T11:02:44Z", "close_reason": "Aligned search footer right actions with split layout and replaced Layout-based canvas centering with deterministic centered Rect math", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-774", "title": "all screens should be the same size as the search view - apply whatever constraints the search view have globally", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T04:01:29Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T10:35:08Z", "started_at": "2026-04-30T10:29:32Z", "closed_at": "2026-04-30T10:35:08Z", "close_reason": "Added shared standard_canvas margin/max-size helper and applied search view constraints across TUI screens and overlays", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-w6y", "title": "Review and prune stale tests", "description": "Review the current test suite and remove tests that cover deprecated behavior, stale expectations, or backwards-compatibility-only behavior no longer relevant to the Rust rewrite.", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T03:52:16Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T04:03:23Z", "started_at": "2026-04-30T03:52:27Z", "closed_at": "2026-04-30T04:03:23Z", "close_reason": "Pruned stale/deprecated/backwards-compat test coverage and verified test suite passes", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-aqi", "title": "Submodule-aware commit/push in dispatcher", "description": "Enable single-store + git-submodule-for-includes workflow. Previously, himitsu set to a path inside a submodule would strand the file: submodule dirty, parent clean, nothing pushed. Dispatcher now commits submodules first, pushes them first, holds back parent push if any submodule push fails. auto_pull gains --recurse-submodules. Safe detached-HEAD auto-checkout only when HEAD matches origin default-branch tip.", "status": "closed", "priority": 2, "issue_type": "feature", "owner": "demo@himitsu.dev", "created_at": "2026-04-24T09:18:44Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-24T09:19:01Z", "closed_at": "2026-04-24T09:19:01Z", "close_reason": "Landed: list_submodules/has_unpushed_commits/ensure_on_branch helpers in git.rs; Context::commit walks dirty submodules then parent, Context::push submodules-first with hold-back on failure, pull_if_remote recurses. 4 new tests covering round-trip with real local bare remotes. 460 tests green.", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-fy3", "title": "himitsu git status should never have unstaged / working changes. every action should commit, and make the git history be essentially an append-only log", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T22:13:42Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T22:40:35Z", "closed_at": "2026-04-21T22:40:35Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-ic2", "title": "himitsu codegen emits .sops.yaml", "description": "Extend `himitsu codegen` (rust/src/cli/codegen.rs) to accept an env label and emit a valid SOPS file at `.sops.yaml`. Today codegen's `--env` flag is already threaded through (see rust/src/cli/codegen.rs:55-56) for language bindings; this task adds a new output mode where the env itself is the target.\n\n## CLI surface\n\n- `himitsu codegen ` \u2014 shorthand for `--lang sops --env --output .sops.yaml`.\n- `himitsu codegen --output path.yaml` \u2014 override output path.\n- Accepts concrete labels (`foo/dev`) and wildcards (`foo/*`); wildcard form emits one file per matched child (`foo-dev.sops.yaml`, `foo-prod.sops.yaml`), or a single merged file if `--merge` is given (decide in review).\n\n## Output format\n\n- Top-level keys come from the resolved env entries (hm-qjz), each mapped to its decrypted secret value.\n- File is encrypted via `sops` rules: either the project's existing `.sops.yaml` rules file or an inline `creation_rules` block derived from himitsu recipients (TBD \u2014 call out in design doc).\n- Generated file contains the same `AUTO-GENERATED` header banner codegen already emits (rust/src/cli/codegen.rs:537).\n\n## Acceptance\n\n- `himitsu codegen foo/dev` produces `foo-dev.sops.yaml` that `sops -d` can decrypt with the configured recipients.\n- Wildcard case produces the expected file set.\n- Errors clearly when the env label is unknown or when captures fail to resolve.", "notes": "## Integration decision (2026-04-21)\n\n**Shell out to the `sops` CLI.** Generate plaintext YAML from the resolved env tree, write it to the output path, then invoke `sops --encrypt --in-place `. Key/recipient resolution is delegated to the user's existing `.sops.yaml` rules file.\n\n- Requires `sops` on PATH \u2014 detect and emit a helpful error if missing.\n- No new crypto crate dependency.\n- Existing `AUTO-GENERATED` header banner applies to the plaintext emission before encryption.", "status": "closed", "priority": 2, "issue_type": "feature", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:39:24Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T21:32:38Z", "closed_at": "2026-04-21T21:32:38Z", "close_reason": "Landed: codegen positional triggers sops path. Resolver->decrypt->YAML->sops encrypt-in-place. Missing-sops error has install hint. 6 new tests green, 1 ignored smoke.", "dependencies": [{"issue_id": "hm-ic2", "depends_on_id": "hm-20e", "type": "blocks", "created_at": "2026-04-21T13:39:42Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-ic2", "depends_on_id": "hm-r2e", "type": "parent", "created_at": "2026-04-21T13:39:45Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-20e", "title": "Env resolver: expand wildcards and capture references", "description": "Central pure-function resolver that both the TUI preview and `himitsu codegen ` depend on. Given the full env map + a target label, return the concrete resolved entries (key \u2192 secret path, no wildcards, no captures remaining).\n\n## Behavior\n\n- Input: `&Config.envs`, target label (e.g. `foo/*` or `foo/dev`).\n- Resolution:\n 1. If label is concrete, return its entries verbatim (Aliases already resolved to path refs).\n 2. If label ends in `/*`, enumerate sibling envs whose labels match `foo/` (or deeper \u2014 TBD in schema design), and for each child, substitute captures into the glob's entries (e.g. `\\$1` \u2192 the matched segment).\n 3. Merge overlapping keys: concrete child entries win over wildcard-inherited ones.\n- Output: `Vec` plus diagnostics for unresolved captures or missing targets.\n\n## Why a dedicated resolver\n\nCodegen (hm-nm9) and the TUI preview (hm-kli) both need identical semantics. Putting it in one place means users never see a mismatch between TUI preview and generated SOPS output.\n\n## Acceptance\n\n- Table-driven tests for: concrete label, wildcard with 0/1/N children, nested wildcards (if allowed by schema), captures in Alias paths, capture index out of range.\n- Resolver returns deterministic order (sort by key) so generated SOPS files are diff-friendly.", "notes": "## Output shape correction (2026-04-21)\n\nThe resolver returns a **nested tree**, not a flat `Vec`. Shape (proposed):\n\n```rust\nenum EnvNode {\n Leaf { secret_path: String },\n Branch(BTreeMap),\n}\n```\n\nReasoning: `foo/*` expansion over children `foo/bar` and `foo/bar/baz` must produce a single nested structure, not a flat list and not multiple resolver invocations. Captures (`\\$1`) still substitute path segments, but the output is always a tree rooted at the queried label.\n\nGlob entries reference secret variables only \u2014 never other env groups. The resolver does not follow cross-env references.\n\nDeterminism: `BTreeMap` gives sorted keys \u2192 diff-friendly downstream codegen.", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:39:15Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T21:12:58Z", "closed_at": "2026-04-21T21:12:58Z", "close_reason": "Wave 2 landed in commit. Resolver returns deterministic EnvNode trees for concrete + wildcard labels. Cache mirrors envs into SQLite with scope isolation, SHA-256 staleness, atomic refresh. 18 new tests green.", "dependencies": [{"issue_id": "hm-20e", "depends_on_id": "hm-5v4", "type": "blocks", "created_at": "2026-04-21T13:39:39Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-20e", "depends_on_id": "hm-r2e", "type": "parent", "created_at": "2026-04-21T13:39:44Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 2, "comment_count": 0} +{"_type": "issue", "id": "hm-zzj", "title": "TUI Envs tab: list, create, edit, delete presets", "description": "Add a dedicated Envs tab alongside the existing TUI views (see rust/src/tui/views/). It becomes the primary UX for authoring preset environments.\n\n## UX sketch\n\n- Left pane: tree of env labels grouped by scope (project first, then global). `foo`, `foo/bar`, `foo/*` render as a hierarchy.\n- Right pane: the selected env's resolved entries \u2014 Singles, Aliases, and the expanded form of any Glob (so users see what `foo/*` actually produces).\n- Keymap reuses the project's existing conventions (rust/src/tui/keymap.rs): `n` new env, `e` edit, `d` delete, `/` search, `Enter` drill in.\n- Inline editor for entry kinds (Single | Glob | Alias) with a live preview column showing capture-group expansions.\n\n## Wiring\n\n- Reads come from the SQLite cache (hm-8ht).\n- Writes go through the mutation layer (hm-d1m); optimistic UI with toast rollback on failure.\n- Scope indicator visible at all times; creation defaults to project scope inside a git repo, global otherwise.\n\n## Acceptance\n\n- Can create `foo`, `foo/bar`, and `foo/*` entries end-to-end from the TUI.\n- Editing an Alias with a capture ref renders the live expansion for each matched child env.\n- Deleting an env removes it from YAML + cache on disk.", "notes": "## Scope reduction (2026-04-21)\n\n**First slice: read-only + delete.** Creation and inline editing are deferred to follow-up issues (to be filed when this one lands).\n\nIncluded in v1:\n- Tree/list view of envs grouped by scope (project/global).\n- Right pane shows resolved entries (Singles, Aliases, expanded Globs via hm-20e).\n- `d` deletes the selected env (with confirmation), routed through the mutation API (hm-d1m).\n- `/` search, scope indicator, navigation.\n\nDeferred to follow-ups:\n- `n` new env flow with inline editor.\n- `e` edit existing entries.\n- Live capture-reference preview editor.\n\nRationale: smallest slice that validates the cache + resolver + mutation stack end-to-end through real UI. Creation UX deserves its own design pass once read-path is proven.", "status": "closed", "priority": 2, "issue_type": "feature", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:39:06Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T21:57:31Z", "closed_at": "2026-04-21T21:57:31Z", "close_reason": "Read+delete TUI tab landed. Epic complete: schema + cache + resolver + mutation + codegen sops + TUI all merged. 5 new view tests; 337 lib tests pass.", "dependencies": [{"issue_id": "hm-zzj", "depends_on_id": "hm-20e", "type": "blocks", "created_at": "2026-04-21T13:39:41Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-zzj", "depends_on_id": "hm-8ht", "type": "blocks", "created_at": "2026-04-21T13:39:40Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-zzj", "depends_on_id": "hm-d1m", "type": "blocks", "created_at": "2026-04-21T13:39:40Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-zzj", "depends_on_id": "hm-r2e", "type": "parent", "created_at": "2026-04-21T13:39:45Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 3, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-d1m", "title": "Config mutation layer: write envs to project or global scope", "description": "Add a mutation API that the TUI and any future CLI subcommands call to create/update/delete env presets. Scope is inferred the same way `codegen` already does it (rust/src/cli/codegen.rs:118-127): walk up for `.himitsu.yaml`, else fall back to global config.\n\n## Requirements\n\n- Single entry point: `envs::upsert(label, entries, scope_hint) -> Result<()>`; sibling `envs::delete(label, scope)`.\n- Preserve YAML formatting/comments where feasible (serde_yaml round-trip is lossy \u2014 evaluate `yaml-rust2` or string-level edits if comments matter here).\n- Atomic writes: temp file + rename so a crash mid-write does not corrupt config.\n- After every mutation, refresh the SQLite cache row for that scope.\n- Validation: reject labels that violate the grammar from the schema-design task.\n\n## Acceptance\n\n- Unit tests for project vs global routing, label validation, atomic write behavior.\n- Integration test: mutate via API \u2192 reload `Config` from disk \u2192 observe change.", "notes": "## YAML fidelity decision (2026-04-21)\n\n**Accept lossy round-trip via `serde_yaml`.** Comments and custom formatting in `.himitsu.yaml` / global config will not survive a mutation. This matches the current codebase pattern and keeps the implementation small.\n\n- Document the lossy behavior in the TUI help panel + user-facing docs.\n- Atomic write (temp file + rename) still required to avoid partial writes.", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:38:54Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T21:23:55Z", "closed_at": "2026-04-21T21:23:55Z", "close_reason": "Landed: upsert/delete/read API with ScopeHint inference, atomic writes, validation, cache refresh. Config gained first-class envs field. 11 new tests green.", "dependencies": [{"issue_id": "hm-d1m", "depends_on_id": "hm-5v4", "type": "blocks", "created_at": "2026-04-21T13:39:37Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-d1m", "depends_on_id": "hm-8ht", "type": "blocks", "created_at": "2026-04-21T13:39:38Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-d1m", "depends_on_id": "hm-r2e", "type": "parent", "created_at": "2026-04-21T13:39:44Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 2, "dependent_count": 1, "comment_count": 0} +{"_type": "issue", "id": "hm-8ht", "title": "SQLite cache for env presets (project + global scope)", "description": "Mirror `envs:` from YAML config into SQLite so the TUI can list/search without re-parsing YAML on every keystroke, and so other subsystems (codegen, check) have a uniform read API.\n\n## Requirements\n\n- Tables (proposed):\n - `envs(id INTEGER PK, label TEXT, scope TEXT CHECK(scope IN ('project','global')), config_path TEXT, mtime INTEGER)`\n - `env_entries(id INTEGER PK, env_id INTEGER REFERENCES envs(id) ON DELETE CASCADE, kind TEXT, value TEXT, alias_key TEXT)`\n- Rebuild strategy: on load, compare config file hash/mtime against cached row; rebuild if stale.\n- Scope handling: project rows keyed by git-root path so switching projects does not pollute.\n- YAML remains source of truth \u2014 every write path must rewrite YAML then refresh the cache; never the reverse.\n\n## Non-goals\n\n- No schema migrations framework yet \u2014 initial table ships with the feature.\n- No cross-device sync; the cache is purely local.\n\n## Acceptance\n\n- Round-trip test: load YAML \u2192 cache \u2192 query \u2192 matches parsed `Config.envs`.\n- Stale detection: mutating YAML on disk triggers cache rebuild on next read.", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:38:47Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T21:12:58Z", "closed_at": "2026-04-21T21:12:58Z", "close_reason": "Wave 2 landed in commit. Resolver returns deterministic EnvNode trees for concrete + wildcard labels. Cache mirrors envs into SQLite with scope isolation, SHA-256 staleness, atomic refresh. 18 new tests green.", "dependencies": [{"issue_id": "hm-8ht", "depends_on_id": "hm-5v4", "type": "blocks", "created_at": "2026-04-21T13:39:36Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-8ht", "depends_on_id": "hm-r2e", "type": "parent", "created_at": "2026-04-21T13:39:43Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 2, "comment_count": 0} +{"_type": "issue", "id": "hm-5v4", "title": "Design env schema: wildcard semantics + capture references", "description": "Formalize the env label/entry model before any TUI or codegen work lands. The current `EnvEntry` enum (rust/src/config/mod.rs:134) has `Single`, `Glob`, `Alias` \u2014 decide how capture references like `/\\$1/postgres-url` are expressed on-disk and in-memory.\n\n## Questions to resolve\n\n- Label grammar: confirm `foo`, `foo/bar`, `foo/*` are the only legal forms; reject `foo/*/bar`? Or allow mid-path wildcards?\n- Does a `foo/*` env enumerate its children by scanning other env labels in config, or by scanning the secret namespace? (Probably: scan sibling env labels that match `foo/`.)\n- Capture-ref syntax: `\\$1`, `\\${1}`, `{{1}}`? Which path segments are captured \u2014 everything matched by `*`?\n- Do captures only make sense inside `Glob` entries, or also inside `Alias.path`? Likely the latter: `Alias { key: DB_URL, path: \\\"/\\$1/postgres-url\\\" }` under a `foo/*` env.\n- Round-trip serde: new `EnvEntry` variant or reuse existing `Glob`/`Alias` with pattern detection?\n\n## Deliverable\n\nA short design doc (in this issue's `--design`) + updated serde tests in rust/src/config/mod.rs covering:\n- Capture-ref in Alias paths\n- Invalid label rejection\n- Expansion semantics (input env set \u2192 resolved concrete entries)", "notes": "## Locked constraints (2026-04-21)\n\n- **Reject mid-path wildcards.** Only `foo`, `foo/bar`, `foo/*` are valid. `foo/*/bar`, `*/foo`, and similar are hard errors at config-parse time.\n- **Glob entries reference secret variables only, never other env groups.** Inside a `foo/*` env, entries describe secret paths (optionally with `\\$1` captures) \u2014 they do not pull in other env labels.\n- **Wildcards produce nested output, never multiple files.** `foo/*` expanded over concrete children `foo/bar`, `foo/bar/baz` yields a single tree:\n ```\n foo:\n bar:\n baz: \n ```\n This mirrors how sops would naturally represent a hierarchical namespace.\n\nThese constraints must be enforced by the label grammar validator and reflected in the resolver's output shape (which is a tree, not a flat list \u2014 see hm-20e).", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:38:39Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T20:51:37Z", "closed_at": "2026-04-21T20:51:37Z", "close_reason": "Schema + validator landed: validate_env_label, parse_captures, validate_envs, ProjectConfig::validate. 15 new tests green, full workspace builds clean. Mid-path wildcards, bare-wildcard, bad chars, capture-in-concrete-env, and high-capture-index all rejected. Resolver (hm-20e) and codegen (hm-ic2) can now consume these helpers.", "dependencies": [{"issue_id": "hm-5v4", "depends_on_id": "hm-r2e", "type": "parent", "created_at": "2026-04-21T13:39:43Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 3, "comment_count": 0} +{"_type": "issue", "id": "hm-r2e", "title": "Preset environment management (TUI tab + codegen integration)", "description": "## Motivation\n\nUsers already declare environment presets in config under `envs:` as a `BTreeMap>` with `Single`, `Glob`, and `Alias` variants (see rust/src/config/mod.rs:116, :134). Today this config is only consumable by read paths \u2014 there is no UX for authoring, visualizing, or sharing envs, and `himitsu codegen` currently emits language bindings rather than deployable secret bundles.\n\nThis epic adds a first-class preset environment manager that:\n- Surfaces envs as a dedicated tab in the TUI\n- Treats env labels as a path namespace: `foo`, `foo/bar`, `foo/*` are all valid, where `foo/*` is a wildcard shortcut that expands across children\n- Supports capture-group references inside wildcard entries, e.g. `/$1/postgres-url` lets `foo/*` expand into `foo/dev/postgres-url`, `foo/prod/postgres-url`, etc.\n- Mutates the correct config file based on context (project `.himitsu.yaml` vs global config)\n- Mirrors the config into the SQLite cache so reads are fast and the TUI stays responsive\n- Extends `himitsu codegen ` to emit `.sops.yaml` \u2014 a valid SOPS-encrypted secret bundle \u2014 so envs become deployable artifacts, not just labels\n\n## Data model\n\nConceptually this is a many-to-many relationship: envs \u2194 secrets. In SQLite this is cached as (example, subject to design child):\n\n- `envs(id, label, scope)` \u2014 `scope` \u2208 {project, global}; `label` stores the full namespaced path (`foo`, `foo/bar`)\n- `env_entries(env_id, kind, value, alias_key, capture_pattern)` \u2014 one row per EnvEntry, preserving Single/Glob/Alias semantics\n\nAuthoritative storage stays YAML; SQLite is a rebuilt cache keyed off config file mtime/hash.\n\n## Scope boundary\n\n- Scope (project vs global) is inferred the same way `codegen` already does it \u2014 walk up for `.himitsu.yaml`, else global.\n- All mutations are writes to the YAML config plus a cache-refresh; no new storage of truth in SQLite.\n\n## Out of scope\n\n- Changing how secrets themselves are stored/encrypted\n- Re-keying or rotation flows (separate surface)\n- Any non-SOPS codegen output format (typescript/go/etc. stay as-is)\n\n## Success criteria\n\n- TUI has an Envs tab that lists, creates, edits, and deletes env presets with wildcard + alias support\n- Wildcard capture references (`$1`, `$2`, ...) resolve correctly in both TUI preview and codegen\n- `himitsu codegen ` produces a `.sops.yaml` that round-trips through `sops` without manual editing\n- SQLite cache stays consistent with YAML after every mutation; restart reflects on-disk state", "status": "closed", "priority": 2, "issue_type": "epic", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:38:26Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T21:57:31Z", "closed_at": "2026-04-21T21:57:31Z", "close_reason": "Read+delete TUI tab landed. Epic complete: schema + cache + resolver + mutation + codegen sops + TUI all merged. 5 new view tests; 337 lib tests pass.", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-vy9", "title": "remove reference to legacy 'groups' command - use recipients in a path-based manner instead, e.g. team/person or team/*", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:26:12Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T20:34:51Z", "closed_at": "2026-04-21T20:34:51Z", "close_reason": "removed group references from README and CLI help, updated to path-based recipients", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-czj", "title": "TUI details/edit should support arbitrary fields (key/value pairs)", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T19:31:36Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T19:41:28Z", "closed_at": "2026-04-18T19:41:28Z", "close_reason": "annotations wired through decode/encode, TUI viewer displays them, edit doc accepts custom key:value as annotations", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-tz3", "title": "TUI should display status of remote store (if its out of sync/unhealthy)", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:53:59Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T16:26:20Z", "closed_at": "2026-04-18T16:26:20Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-6mo", "title": "get rid of groups. recipients should allow for path-based names which would allow the equivalent of groups by just using foo/*", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:53:12Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T16:26:21Z", "closed_at": "2026-04-18T16:26:21Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-6nb", "title": "add export command that lets you create SOPS-compatble files, mapping multiple keys to secrets, with support for foo/bar/* which would include all matching secrets with their default key", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:51:13Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T16:26:21Z", "closed_at": "2026-04-18T16:26:21Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-jk1", "title": "add support for SOPS to import", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:49:37Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T16:26:21Z", "closed_at": "2026-04-18T16:26:21Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-far", "title": "import command should allow importing the whole vault, including a dry run", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:49:05Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T16:26:21Z", "closed_at": "2026-04-18T16:26:21Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-yxb", "title": "search should show time ago format for created", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:38:47Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T15:54:48Z", "closed_at": "2026-04-18T15:54:48Z", "close_reason": "Implemented in TUI search view: humanized dates via humanize_age() and description column added", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-zzu", "title": "search page should show excerpt of description.", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:38:26Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T15:54:48Z", "closed_at": "2026-04-18T15:54:48Z", "close_reason": "Implemented in TUI search view: humanized dates via humanize_age() and description column added", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-pmy", "title": "every mutation should trigger a git sync to remote if configured", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:23:14Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T16:26:21Z", "closed_at": "2026-04-18T16:26:21Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-bln", "title": "himitsu 'get/read' completions should show available secrets, not files in the current directory", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T14:11:07Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-17T07:14:55Z", "closed_at": "2026-04-17T07:14:55Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-p8g", "title": "add import command, start with import from 1password via op cli", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T14:09:11Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-17T07:14:57Z", "closed_at": "2026-04-17T07:14:57Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-t81", "title": "use 'n days ago' format for date in search. include description, and put store as last column.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T14:08:28Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-17T07:14:58Z", "closed_at": "2026-04-17T07:14:58Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-f6z", "title": "TUI: integration test harness driving App via simulated key events", "description": "Build a test harness that constructs App, feeds a sequence of crossterm::event::KeyEvent through the main loop tick, and asserts on rendered buffer state. Enables end-to-end coverage of search\u2192viewer\u2192new-secret flows without spawning a real terminal.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T06:53:32Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-15T07:01:04Z", "closed_at": "2026-04-15T07:01:04Z", "close_reason": "Merged via parallel worktree agents; cargo test --bin himitsu tui::harness:: \u2192 3 passed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-3cm", "title": "TUI: configurable keybindings via config file", "description": "Allow users to remap TUI key bindings through the himitsu config file. Define a KeyMap struct, default to current bindings, let config override by action name (e.g. 'quit', 'new_secret', 'switch_store'). Threaded through App to each view's handle_key.", "status": "closed", "priority": 2, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T06:53:30Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-15T07:19:59Z", "closed_at": "2026-04-15T07:19:59Z", "close_reason": "Merged to main; 231 tests pass (toast + configurable keymap). Conflicts in app.rs/search.rs/secret_viewer.rs resolved by keeping toast-based action returns on top of keymap dispatch", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-o15", "title": "TUI: status-line toast for transient feedback", "description": "Add a toast/status-line component that renders transient messages (saved, copied, deleted, error) for N seconds then clears. Currently feedback is inline-only in individual views. Should be globally mounted in App so every view can emit a toast.", "status": "closed", "priority": 2, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T06:53:21Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-15T07:19:59Z", "closed_at": "2026-04-15T07:19:59Z", "close_reason": "Merged to main; 231 tests pass (toast + configurable keymap). Conflicts in app.rs/search.rs/secret_viewer.rs resolved by keeping toast-based action returns on top of keymap dispatch", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-64c", "title": "record new demo after major tui changes", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T02:28:44Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-15T02:54:24Z", "closed_at": "2026-04-15T02:54:24Z", "close_reason": "Rewrote and re-recorded all 6 TUI tapes (us-008..us-013) against search-as-root + new-secret metadata + store picker flow", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-r4i", "title": "new secret flow should alow setting metadata, not just edit", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-14T14:23:45Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-15T02:46:54Z", "closed_at": "2026-04-15T02:46:54Z", "close_reason": "metadata fields (description/url/totp/env_key/expires_at) added to new-secret form with tab nav, validation, 13 new tests", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-y6n", "title": "provide alternative hotkey for ctrl+s to save, as it is commmonly set in tmux and conflicts", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-14T14:23:19Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-14T14:28:32Z", "closed_at": "2026-04-14T14:28:32Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-72k", "title": "get rid of 'env', its being used as a group which is incorrect. it shouldnt even show in the search tui. env is for codegen which I will spec out later", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-14T14:20:35Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-14T14:28:32Z", "closed_at": "2026-04-14T14:28:32Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-8z8", "title": "search view should have a hotkey for copying to clipboard", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T18:07:09Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-14T14:23:23Z", "closed_at": "2026-04-14T14:23:23Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-57k", "title": "tui should render all stores together, rendering the store as a column", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T18:06:03Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T18:11:43Z", "closed_at": "2026-04-13T18:11:43Z", "close_reason": "multi-store grouping with per-store header rows", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-rnx", "title": "TUI secret viewer should render all decoded metadata", "description": "The secret viewer in the TUI only shows path/env/created_at/lastmodified/recipients. It should also render description, url, totp, expires_at, and env_key from the decoded SecretValue so users can see everything they just edited without round-tripping through the editor.", "status": "closed", "priority": 2, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T18:05:44Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T18:12:51Z", "closed_at": "2026-04-13T18:12:51Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-ldq", "title": "Add env_key metadata field to SecretValue", "description": "Prepare for 'himitsu exec path/* -- cmd' by letting each secret carry a default environment variable name. When unset, callers derive one from the path (typically last segment uppercased). Description field already exists from hm-dvs/hm-de4.", "acceptance_criteria": "proto field added; --env-key flag on set; validates as POSIX env var name; surfaced by himitsu get; round-trips through encode/decode", "status": "closed", "priority": 2, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T17:20:50Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T17:21:05Z", "closed_at": "2026-04-13T17:21:05Z", "close_reason": "env_key field added end-to-end with validation + tests", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-tcp", "title": "columns should have header labels in tui", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T17:17:56Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T18:12:51Z", "closed_at": "2026-04-13T18:12:51Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-3ir", "title": "the default tui should use a search bar with fuzzy find instead of the two-column navigator", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T04:04:48Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T04:11:38Z", "closed_at": "2026-04-13T04:11:38Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-jjc", "title": "add fuzzy find to search", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T03:14:50Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-yu4", "title": "recipients should allow for adding metadata. adding a recipient should additionally prompt for a description (optional)", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T03:11:02Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-dvs", "title": "add a field called totp that can be used to associate a TOTP key for any secret. also add a URL field that can be used to store the website it is associated with", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T03:10:15Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-de4", "title": "add a expires_at field to metadata, can be optionally set to remind when the secret is about to expire", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T03:08:26Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-6xd", "title": ".himitsu/recipients should contain a flat list of recipients. the group should not be determined by folder nesting, but rather by a mapping in .himitsu.yaml - this solves the issue where a recipient cannot belong to multiple groups without duplicating files", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T03:06:39Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-ftf", "title": "implement himitsu read/write which are like get/set except they return just the plaintext secret, render no metadata, and are meant to be used in other tools", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T03:04:41Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-381", "title": "implement completions", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T03:02:56Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-28r", "title": "himitsu search should look similar to gh repo list", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:56:40Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-iq6", "title": "himitsu tui navigation should look more like a table that shows the path, description, last modified date, and store it belongs to. folders should be sorted first just like in the file explorer in most IDEs", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:40:26Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:51:25Z", "closed_at": "2026-04-13T03:51:25Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-6vb.6", "title": "US-013: Store/env switch from dashboard", "description": "Let the user switch the active store (recipients/data dir) without restarting the TUI. Add a store picker (e.g. 's' key) that lists configured stores from config and rebuilds the dashboard against the chosen store. Scope of 'env switch' is unclear \u2014 envs are derived from path prefixes, so selection already works; confirm with user whether this bead should also cover a true multi-context switch.", "notes": "Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape \u2192 regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:14:51Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:12:16Z", "closed_at": "2026-04-13T03:12:16Z", "close_reason": "Closed", "dependencies": [{"issue_id": "hm-6vb.6", "depends_on_id": "hm-6vb", "type": "parent-child", "created_at": "2026-04-12T19:14:50Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-6vb.3", "title": "US-011: Drill into secret from dashboard", "description": "Pressing Enter on a secret in the dashboard's right-hand list currently does nothing. Wire it to open the SecretViewerView directly, skipping the search detour. Also add j/k/arrow navigation within the secret list (currently only env list navigates).", "notes": "Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape \u2192 regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:14:40Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:12:15Z", "closed_at": "2026-04-13T03:12:15Z", "close_reason": "Closed", "dependencies": [{"issue_id": "hm-6vb.3", "depends_on_id": "hm-6vb", "type": "parent-child", "created_at": "2026-04-12T19:14:39Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-6vb.4", "title": "US-012: Help overlay ('?')", "description": "Add a modal help overlay bound to '?' (and maybe F1) that lists all keybindings for the current view. Should be view-aware: dashboard, search, and viewer each show their own bindings. Dismiss with Esc or '?'.", "notes": "Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape \u2192 regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:14:40Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:12:15Z", "closed_at": "2026-04-13T03:12:15Z", "close_reason": "Closed", "dependencies": [{"issue_id": "hm-6vb.4", "depends_on_id": "hm-6vb", "type": "parent-child", "created_at": "2026-04-12T19:14:39Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-6vb.5", "title": "US-008: Create secret from TUI", "description": "Add a 'new secret' flow reachable from the dashboard (e.g. 'n' keybinding). Should prompt for env/path and value (value input hidden), then call the same internal path as 'himitsu set' \u2014 no subprocess. On success, refresh the dashboard list and select the new entry.", "notes": "Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape \u2192 regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:14:40Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:12:15Z", "closed_at": "2026-04-13T03:12:15Z", "close_reason": "Closed", "dependencies": [{"issue_id": "hm-6vb.5", "depends_on_id": "hm-6vb", "type": "parent-child", "created_at": "2026-04-12T19:14:39Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-6vb.1", "title": "US-009: Delete secret from TUI", "description": "Add a delete action in the secret viewer and/or search result list (e.g. 'd' with a confirmation prompt). Wire to the existing store delete path. Refresh the parent view on success and surface errors as a status line.", "notes": "Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape \u2192 regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:14:39Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:12:15Z", "closed_at": "2026-04-13T03:12:15Z", "close_reason": "Closed", "dependencies": [{"issue_id": "hm-6vb.1", "depends_on_id": "hm-6vb", "type": "parent-child", "created_at": "2026-04-12T19:14:39Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-6vb.2", "title": "US-010: Edit secret value from TUI", "description": "Currently 'e' on the secret viewer rekeys \u2014 rebind rekey and add a real edit flow that decrypts the current value, opens it in an in-TUI editor (or $EDITOR fallback with terminal suspend, matching the init wizard pattern), and re-encrypts on save. Decide with the user whether to preserve 'e' for edit and move rekey to a less prominent key.", "notes": "Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape \u2192 regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:14:39Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:12:15Z", "closed_at": "2026-04-13T03:12:15Z", "close_reason": "Closed", "dependencies": [{"issue_id": "hm-6vb.2", "depends_on_id": "hm-6vb", "type": "parent-child", "created_at": "2026-04-12T19:14:39Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-6vb", "title": "TUI Phase 2: CRUD + UX polish", "description": "Follow-on epic to hm-nck. The Rust/ratatui TUI shipped as a read-only explorer (dashboard \u2192 search \u2192 viewer). This epic adds the missing CRUD actions (create/edit/delete), first-class navigation from the dashboard, a help overlay, and store/env switching so the TUI stands on its own without shelling out to the CLI for common operations.", "status": "closed", "priority": 2, "issue_type": "epic", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:14:19Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:12:16Z", "closed_at": "2026-04-13T03:12:16Z", "close_reason": "all steps complete", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-c84", "title": "US-007: Global navigation and keybindings", "description": "Consistent global keybindings: q quits from dashboard (ignored in inputs), Ctrl-C always quits with terminal cleanup, Esc goes back one view, / enters search from dashboard. Footer hint bar on every view. Resize event re-lays out without panic. Event loop uses crossterm::event::poll with sane timeout (no busy spin).", "acceptance_criteria": "q/Ctrl-C/Esc/'/' bindings wired; footer hint on each view; resize handled; poll-based event loop", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:14:48Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T02:10:40Z", "closed_at": "2026-04-13T02:10:40Z", "close_reason": "Global nav + keybindings wired via DashboardAction router in 02af8ab. q/Ctrl-C/Esc/'/' bindings live; footer on every view; poll-based loop with 100ms timeout; resize auto-re-lays out via ratatui frame draw each tick.", "dependencies": [{"issue_id": "hm-c84", "depends_on_id": "hm-djm", "type": "blocks", "created_at": "2026-04-12T18:15:19Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-c84", "depends_on_id": "hm-nck", "type": "parent-child", "created_at": "2026-04-12T18:15:13Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-xe5", "title": "US-006: Secret viewer", "description": "Show path, env, recipients, created_at, lastmodified. Value hidden by default; 'r' reveals via existing get code path. 'y' copies to clipboard via arboard (graceful no-op if unavailable). 'e' triggers rekey for the current secret. Esc returns to previous view.", "acceptance_criteria": "metadata shown; r reveals value; y copies via arboard; e triggers rekey; esc returns", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:14:39Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T02:09:54Z", "closed_at": "2026-04-13T02:09:54Z", "close_reason": "Implemented SecretViewerView with reveal/copy/rekey/esc, wired into router via SearchAction::OpenViewer; 6 tests passing", "dependencies": [{"issue_id": "hm-xe5", "depends_on_id": "hm-nck", "type": "parent-child", "created_at": "2026-04-12T18:15:13Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-xe5", "depends_on_id": "hm-rd2", "type": "blocks", "created_at": "2026-04-12T18:15:20Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-pp9", "title": "US-005: Search view", "description": "/ from dashboard opens search with focused text input. Results update as query changes. Uses extracted core of crate::cli::search (refactor search::run to split IO from logic). Results show store, path, created_at. Enter on a result navigates to secret viewer. Esc returns to dashboard.", "acceptance_criteria": "search view opens on /; live results; uses extracted search core; Enter opens viewer; Esc returns to dashboard", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:14:32Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T02:09:46Z", "closed_at": "2026-04-13T02:09:46Z", "close_reason": "Landed in 02af8ab", "dependencies": [{"issue_id": "hm-pp9", "depends_on_id": "hm-nck", "type": "parent-child", "created_at": "2026-04-12T18:15:12Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-pp9", "depends_on_id": "hm-rd2", "type": "blocks", "created_at": "2026-04-12T18:15:19Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-rd2", "title": "US-004: Dashboard view (envs + secrets)", "description": "Two-pane layout: left = environments list, right = secrets for selected env. Header shows active store slug and env count. Arrow keys / j/k navigate envs. Right pane updates live on selection change. Data via internal Rust APIs (ls equivalent), not subprocess. Empty state message when no envs. Footer lists keybindings.", "acceptance_criteria": "two-pane layout; live right-pane updates; uses internal APIs not subprocess; empty state handled; footer keybindings shown", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:14:25Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T01:52:00Z", "closed_at": "2026-04-13T01:52:00Z", "close_reason": "US-003 init wizard + US-004 dashboard view landed; DashboardView ported to ratatui with envs/secrets two-pane layout, run_init_flow event loop wired.", "dependencies": [{"issue_id": "hm-rd2", "depends_on_id": "hm-djm", "type": "blocks", "created_at": "2026-04-12T18:15:19Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-rd2", "depends_on_id": "hm-nck", "type": "parent-child", "created_at": "2026-04-12T18:15:12Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 2, "comment_count": 0} +{"_type": "issue", "id": "hm-b49", "title": "US-003: Init wizard view (ratatui)", "description": "Port the 3-step init wizard to ratatui: Step 1 data dir text input pre-filled from config::data_dir(); Step 2 remote slug validated with config::validate_remote_slug; Step 3 key provider select (hidden if macOS Keychain unavailable). Enter advances, Esc back, Ctrl-C aborts. On confirm calls init::run_init directly (not subprocess). Success screen shows pubkey and advances to dashboard. Replaces init::run_wizard cliclack path when TTY and no --json/--no-tui.", "acceptance_criteria": "three steps rendered; validation wired; calls init::run_init directly; cliclack path removed for TTY case; success screen advances to dashboard", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:14:18Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T01:52:37Z", "closed_at": "2026-04-13T01:52:37Z", "close_reason": "ratatui init wizard landed: 3-step flow (data dir/remote slug/key provider), validation wired, calls init::run_init directly, cliclack TTY path removed, success advances to dashboard", "dependencies": [{"issue_id": "hm-b49", "depends_on_id": "hm-djm", "type": "blocks", "created_at": "2026-04-12T18:15:18Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-b49", "depends_on_id": "hm-nck", "type": "parent-child", "created_at": "2026-04-12T18:15:11Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-4y7", "title": "US-002: Remove legacy Bun/OpenTUI TUI", "description": "Delete the entire tui/ directory and remove himitsuTUI derivation + bun2nix input from flake.nix. Remove any himitsu-tui references from nativeBuildInputs and the HIMITSU_BIN env forwarding from launch_tui().", "acceptance_criteria": "tui/ directory deleted; flake.nix has no himitsuTUI/bun2nix refs; HIMITSU_BIN forwarding removed; nix build still succeeds", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:14:08Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T01:43:39Z", "closed_at": "2026-04-13T01:43:39Z", "close_reason": "Deleted tui/, dropped bun2nix input, removed himitsuTUI deriv. nix flake check + cargo test/clippy/fmt all green.", "dependencies": [{"issue_id": "hm-4y7", "depends_on_id": "hm-djm", "type": "blocks", "created_at": "2026-04-12T18:15:18Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-4y7", "depends_on_id": "hm-nck", "type": "parent-child", "created_at": "2026-04-12T18:15:11Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-djm", "title": "US-001: Scaffold ratatui module and wire up entry point", "description": "Add ratatui/crossterm to rust/Cargo.toml and create rust/src/tui/ with mod.rs, app.rs, event.rs. Public entry himitsu::tui::run() installs/tears down raw mode + alternate screen, runs a no-op event loop that exits on q or Ctrl-C. Cli::launch_tui() in rust/src/cli/mod.rs calls tui::run() directly instead of spawning himitsu-tui. Terminal state restored on panic via drop guard or set_hook.", "acceptance_criteria": "ratatui+crossterm in Cargo.toml; rust/src/tui/{mod,app,event}.rs created; himitsu::tui::run() exists; launch_tui calls it in-process; raw mode restored on panic", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:14:01Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T01:17:48Z", "closed_at": "2026-04-13T01:17:48Z", "close_reason": "Closed", "dependencies": [{"issue_id": "hm-djm", "depends_on_id": "hm-nck", "type": "parent-child", "created_at": "2026-04-12T18:15:11Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 4, "comment_count": 0} +{"_type": "issue", "id": "hm-nck", "title": "Replace OpenTUI TUI with Rust/ratatui", "description": "Replace the Bun + @opentui/core TUI (in tui/, launched as a subprocess via himitsu-tui) with a pure-Rust TUI built on ratatui + crossterm, living inside the existing himitsu binary at rust/src/tui/. Running 'himitsu' with no subcommand will launch the new TUI in-process, eliminating the subprocess hop, the HIMITSU_BIN fallback, and the entire Bun toolchain from the build. Full PRD in commit history / conversation.", "status": "closed", "priority": 2, "issue_type": "epic", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:13:45Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T02:10:40Z", "closed_at": "2026-04-13T02:10:40Z", "close_reason": "all steps complete", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-xx0", "title": "himitsu 'add' should behave like an alias for himitsu 'set'", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-12T23:51:12Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T01:17:38Z", "closed_at": "2026-04-13T01:17:38Z", "close_reason": "Added clap alias='add' to Set command in rust/src/cli/mod.rs", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-3w1", "title": "running 'himitsu' with no args should launch the tui", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-12T23:50:49Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T01:17:38Z", "closed_at": "2026-04-13T01:17:38Z", "close_reason": "Already implemented \u2014 cdf8757 feat: launch TUI when himitsu is run with no subcommand", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-457", "title": "running 'himitsu' with no args should launch the tui", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-12T13:39:46Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-12T13:47:48Z", "closed_at": "2026-04-12T13:47:48Z", "close_reason": "Implemented: running himitsu with no args now launches the TUI (himitsu-tui binary). Falls back to a helpful error if TUI not installed.", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-anf", "title": "himitsu 'add' should behave like an alias for himitsu 'set'", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-12T13:38:51Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T01:17:38Z", "closed_at": "2026-04-13T01:17:38Z", "close_reason": "Added clap alias='add' to Set command in rust/src/cli/mod.rs", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-3us", "title": "Document external import/export requirements", "description": "Add README notes for external tool requirements used by import/export/generate flows, including op for 1Password and sops for encrypted YAML/JSON output.", "status": "closed", "priority": 3, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:51:24Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:01:09Z", "started_at": "2026-05-03T21:59:16Z", "closed_at": "2026-05-03T22:01:09Z", "close_reason": "Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync & Store Health section (auto-commit/push, --no-push, store health states), new Demo & Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-u7g", "title": "Add README demo recording instructions", "description": "Add a Demo or Recording section with the canonical local command to regenerate the VHS demo: cargo build --release && vhs demo/demo.tape, plus CI scratch-output behavior.", "status": "closed", "priority": 3, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:51:24Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:01:09Z", "started_at": "2026-05-03T21:59:16Z", "closed_at": "2026-05-03T22:01:09Z", "close_reason": "Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync & Store Health section (auto-commit/push, --no-push, store health states), new Demo & Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-9lg", "title": "Clarify README demo GIF and tape roles", "description": "Update README demo references to use demo/demo-vhs.gif as the canonical polished demo and explain demo/tui-us-* tapes as per-story regression/demo artifacts.", "status": "closed", "priority": 3, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:51:23Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:01:09Z", "started_at": "2026-05-03T21:59:17Z", "closed_at": "2026-05-03T22:01:09Z", "close_reason": "Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync & Store Health section (auto-commit/push, --no-push, store health states), new Demo & Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-cfo", "title": "Distribute TUI footer actions evenly", "description": "Render footer action groups in evenly distributed segments instead of left/right weighted alignment.", "status": "closed", "priority": 3, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T11:54:15Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T11:56:36Z", "started_at": "2026-04-30T11:54:26Z", "closed_at": "2026-04-30T11:56:36Z", "close_reason": "Added shared distributed footer renderer and applied it to search and envs footers", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-7q6", "title": "Align TUI footer right actions", "description": "Use a split footer layout so the right-most footer actions render flush right instead of relying on spacer text.", "status": "closed", "priority": 3, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T10:57:00Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T10:58:24Z", "started_at": "2026-04-30T10:57:22Z", "closed_at": "2026-04-30T10:58:24Z", "close_reason": "Updated envs footer to split left actions from right-aligned help/back actions using a horizontal layout", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-cpf", "title": "TUI envs editor supports multi-entry envs", "description": "The envs edit flow currently supports single-entry env labels and returns an error for labels with multiple EnvEntry rows. Extend the editor state to hold multiple entry rows with add/remove controls so existing multi-entry envs can be edited in place.", "status": "closed", "priority": 3, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T10:45:06Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T23:23:27Z", "started_at": "2026-04-30T23:23:04Z", "closed_at": "2026-04-30T23:23:27Z", "close_reason": "Superseded by hm-6qj envs revamp, which redesigns the editor with full multi-entry/glob support from the ground up.", "dependencies": [{"issue_id": "hm-cpf", "depends_on_id": "hm-h9m", "type": "discovered-from", "created_at": "2026-04-30T03:45:06Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-h9m", "title": "TUI envs tab: edit existing env with live capture preview", "description": "Follow-up to hm-zzj. v1 ships read + delete; this adds `e` to edit an existing env's entries.\n\n## UX\n\n- `e` opens the same editor as the create flow (file separately as hm-zzj-followup-create) prefilled with the selected env's current entries.\n- Right pane continues to show the live `env_resolver::resolve` output as the user edits, so capture-ref expansions (`/$1/postgres-url` against actual store secrets) update in real time.\n- Save calls `envs_mut::upsert` (which is upsert semantics \u2014 replaces existing rows).\n\n## Notes\n\n- Depends on the create flow (shares the editor widget). Build the editor once, reuse it.\n- For wildcard envs, the live preview must enumerate `available_secrets` via `crate::remote::store::list_secrets` to compute capture matches.", "status": "closed", "priority": 3, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T21:58:07Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T10:45:15Z", "started_at": "2026-04-30T10:43:17Z", "closed_at": "2026-04-30T10:45:15Z", "close_reason": "Added env edit flow with prefilled editor, Ctrl-S upsert save, live preview while editing, and single-entry edit tests; filed hm-cpf for multi-entry editing", "dependencies": [{"issue_id": "hm-h9m", "depends_on_id": "hm-udz", "type": "blocks", "created_at": "2026-04-21T14:58:17Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-udz", "title": "TUI envs tab: create new env flow", "description": "Follow-up to hm-zzj. v1 of the envs tab shipped read + delete only. This adds the `n` flow to create a new env from inside the tab.\n\n## UX\n\n- `n` opens an inline editor split with two fields:\n 1. Label input \u2014 validated live against `validate_env_label`. Show grammar hints (concrete vs `/*`).\n 2. Entries list \u2014 repeatable rows, each picking a kind (Single | Glob | Alias) and the path/key fields.\n- Save (`Ctrl+S` or `Enter` on a \"Save\" button) calls `envs_mut::upsert(label, entries, scope_hint, cwd)`; on success, reload and select the new label. On error, toast the message and keep the editor open.\n- `Esc` cancels with a confirmation if the form is dirty.\n\n## Wiring\n\n- Route through the same `View::Envs` slot or add a sub-modal \u2014 your call when implementing.\n- Reuse the keymap binding `keymap.envs` already defined; add a sub-binding under it for `n`.", "status": "closed", "priority": 3, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T21:58:07Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T10:41:14Z", "started_at": "2026-04-30T10:37:58Z", "closed_at": "2026-04-30T10:41:14Z", "close_reason": "Added TUI envs new-env flow with inline editor, live validation, save via envs_mut::upsert, toasts, and tests", "dependency_count": 0, "dependent_count": 1, "comment_count": 0} +{"_type": "issue", "id": "hm-79o", "title": "Docs: update README for search-as-root TUI flow", "description": "README still references the dashboard-root flow. Update screenshots/gifs to match search-as-root, refresh the key-binding table to list current shortcuts (Ctrl+N new, Ctrl+S store, Ctrl+Y copy, ? help, Esc quit, e edit, d delete with y/n confirm).", "status": "closed", "priority": 3, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T06:53:34Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-15T07:01:04Z", "closed_at": "2026-04-15T07:01:04Z", "close_reason": "Merged via parallel worktree agents; cargo test --bin himitsu tui::harness:: \u2192 3 passed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type": "issue", "id": "hm-6ia", "title": "CI: run vhs against demo tapes to catch TUI flow regressions", "description": "Add a GitHub Actions job that installs vhs and runs the us-008..us-013 tapes (non-interactive, no gif diffing needed \u2014 just exit code) so broken tapes fail CI before they land. Cache cargo build, reuse release binary.", "status": "closed", "priority": 3, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T06:53:33Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-15T07:01:04Z", "closed_at": "2026-04-15T07:01:04Z", "close_reason": "Merged via parallel worktree agents; cargo test --bin himitsu tui::harness:: \u2192 3 passed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d70a800..4284897 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -20,6 +20,14 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt + - name: Install protobuf compiler + run: | + if [[ "${{ runner.os }}" == "Linux" ]]; then + sudo apt-get update + sudo apt-get install -y protobuf-compiler + elif [[ "${{ runner.os }}" == "macOS" ]]; then + brew install protobuf + fi - run: cargo fmt -- --check - run: cargo clippy -- -D warnings - run: cargo test diff --git a/.github/workflows/vhs.yml b/.github/workflows/vhs.yml index fe162a7..3d2dcd6 100644 --- a/.github/workflows/vhs.yml +++ b/.github/workflows/vhs.yml @@ -20,6 +20,7 @@ on: env: CARGO_TERM_COLOR: always + VHS_VERSION: 0.11.0 jobs: vhs: @@ -45,10 +46,14 @@ jobs: - name: Install VHS runtime deps run: | sudo apt-get update - sudo apt-get install -y ffmpeg ttyd + sudo apt-get install -y ffmpeg protobuf-compiler ttyd - name: Install VHS - uses: charmbracelet/vhs-action@v2 + run: | + curl -fsSL \ + "https://github.com/charmbracelet/vhs/releases/download/v${VHS_VERSION}/vhs_${VHS_VERSION}_amd64.deb" \ + -o /tmp/vhs.deb + sudo apt-get install -y /tmp/vhs.deb - name: Verify VHS install run: vhs --version diff --git a/flake.nix b/flake.nix index 604169c..6f092bd 100644 --- a/flake.nix +++ b/flake.nix @@ -124,7 +124,7 @@ devShells.coverage = pkgs.mkShell { inputsFrom = [ - derivation + self.devShells.${system}.default ]; }; diff --git a/rust/src/cli/exec.rs b/rust/src/cli/exec.rs index fc7726f..e523958 100644 --- a/rust/src/cli/exec.rs +++ b/rust/src/cli/exec.rs @@ -196,7 +196,11 @@ fn build_env_map( let mut env_map: BTreeMap = BTreeMap::new(); for (r, decoded) in items { - if !want_tags.is_empty() && !want_tags.iter().all(|t| decoded.tags.iter().any(|d| d == t)) { + if !want_tags.is_empty() + && !want_tags + .iter() + .all(|t| decoded.tags.iter().any(|d| d == t)) + { continue; } @@ -387,7 +391,10 @@ mod tests { // Two secrets resolving to the same env-var name. let items = vec![ (rref("a/api-key", None), decoded("first", "", &[])), - (rref("b/API_KEY", Some("API_KEY")), decoded("second", "", &[])), + ( + rref("b/API_KEY", Some("API_KEY")), + decoded("second", "", &[]), + ), ]; let err = build_env_map(items, &[]).unwrap_err(); let msg = err.to_string(); @@ -424,10 +431,9 @@ mod tests { args: ExecArgs, } - let cli = Cli::try_parse_from([ - "test", "prod/API_KEY", "--", "node", "-e", "console.log(1)", - ]) - .unwrap(); + let cli = + Cli::try_parse_from(["test", "prod/API_KEY", "--", "node", "-e", "console.log(1)"]) + .unwrap(); assert_eq!(cli.args.r#ref, "prod/API_KEY"); assert_eq!(cli.args.command, vec!["node", "-e", "console.log(1)"]); assert!(!cli.args.clean); diff --git a/rust/src/cli/import.rs b/rust/src/cli/import.rs index 455d059..8fcf55d 100644 --- a/rust/src/cli/import.rs +++ b/rust/src/cli/import.rs @@ -1039,10 +1039,7 @@ mod tests { normalize_key_path("database/HOST_NAME"), "database/host-name" ); - assert_eq!( - normalize_key_path("PROD/API_KEY"), - "prod/api-key" - ); + assert_eq!(normalize_key_path("PROD/API_KEY"), "prod/api-key"); assert_eq!(normalize_key_path("simple"), "simple"); } diff --git a/rust/src/cli/join.rs b/rust/src/cli/join.rs index b6895df..50c4d7a 100644 --- a/rust/src/cli/join.rs +++ b/rust/src/cli/join.rs @@ -61,9 +61,7 @@ pub fn run(args: JoinArgs, ctx: &Context) -> Result<()> { fn read_own_pubkey(ctx: &Context) -> Result { let key_path = ctx.key_path(); let contents = std::fs::read_to_string(&key_path).map_err(|_| { - HimitsuError::Recipient( - "no age key found — run `himitsu init` first".into(), - ) + HimitsuError::Recipient("no age key found — run `himitsu init` first".into()) })?; for line in contents.lines() { if let Some(rest) = line.strip_prefix("# public key: ") { @@ -100,7 +98,6 @@ pub fn is_self_recipient(ctx: &Context) -> bool { mod tests { use super::*; use crate::remote::store as rstore; - use std::path::PathBuf; use tempfile::TempDir; fn mk_ctx_with_key(tmp: &TempDir) -> Context { @@ -139,7 +136,14 @@ mod tests { .unwrap(); assert!(!is_self_recipient(&ctx)); - run(JoinArgs { name: None, no_push: true }, &ctx).unwrap(); + run( + JoinArgs { + name: None, + no_push: true, + }, + &ctx, + ) + .unwrap(); assert!(is_self_recipient(&ctx)); } @@ -149,9 +153,23 @@ mod tests { let tmp = TempDir::new().unwrap(); let ctx = mk_ctx_with_key(&tmp); - run(JoinArgs { name: Some("me".into()), no_push: true }, &ctx).unwrap(); + run( + JoinArgs { + name: Some("me".into()), + no_push: true, + }, + &ctx, + ) + .unwrap(); // Second call should succeed silently - run(JoinArgs { name: Some("me".into()), no_push: true }, &ctx).unwrap(); + run( + JoinArgs { + name: Some("me".into()), + no_push: true, + }, + &ctx, + ) + .unwrap(); } #[test] @@ -168,7 +186,13 @@ mod tests { ) .unwrap(); - let result = run(JoinArgs { name: None, no_push: true }, &ctx); + let result = run( + JoinArgs { + name: None, + no_push: true, + }, + &ctx, + ); assert!(result.is_err()); } diff --git a/rust/src/cli/search.rs b/rust/src/cli/search.rs index 145ea7a..051b128 100644 --- a/rust/src/cli/search.rs +++ b/rust/src/cli/search.rs @@ -11,6 +11,7 @@ use super::Context; use crate::crypto::{age, secret_value, tags}; use crate::error::{HimitsuError, Result}; use crate::remote::store; +use crate::suggest; /// Search secrets across all known stores. #[derive(Debug, Args)] @@ -71,11 +72,7 @@ pub struct SearchResult { /// (AND-semantics). An empty slice disables tag filtering. Validation of /// individual tag strings is the caller's responsibility — see [`run`] for /// the CLI path that runs them through [`crate::crypto::tags::validate_tag`]. -pub fn search_core( - ctx: &Context, - query: &str, - tag_filter: &[String], -) -> Result> { +pub fn search_core(ctx: &Context, query: &str, tag_filter: &[String]) -> Result> { let mut candidates: Vec = Vec::new(); // Try to load the age identity once so we can best-effort extract the @@ -231,6 +228,20 @@ pub fn run(args: SearchArgs, ctx: &Context) -> Result<()> { print_table(&results, &args.query, use_color, Utc::now()); } + // "Did you mean ..." suggestion: only when the user typed a real query, + // got zero hits, and isn't filtering by tags (a tag-mismatch is a + // different kind of empty). The candidate corpus is every path returned + // by an unfiltered `search_core` so suggestions stay aligned with what + // the search actually traverses. + if !args.json && results.is_empty() && args.tags.is_empty() && !args.query.trim().is_empty() { + let all = search_core(ctx, "", &[])?; + let candidates: Vec = all.into_iter().map(|r| r.path).collect(); + let max_dist = suggest::default_max_distance(&args.query); + if let Some(hit) = suggest::suggest_closest(&args.query, &candidates, max_dist) { + eprintln!("did you mean {hit}?"); + } + } + Ok(()) } diff --git a/rust/src/cli/set.rs b/rust/src/cli/set.rs index e859e89..f3c0a60 100644 --- a/rust/src/cli/set.rs +++ b/rust/src/cli/set.rs @@ -302,8 +302,18 @@ mod tests { #[test] fn tag_flag_accumulates_multiple_invocations() { - let a = parse(&["prod/API_KEY", "secret-value", "--tag", "pci", "--tag", "rotate-2026-q1"]); - assert_eq!(a.tags, vec!["pci".to_string(), "rotate-2026-q1".to_string()]); + let a = parse(&[ + "prod/API_KEY", + "secret-value", + "--tag", + "pci", + "--tag", + "rotate-2026-q1", + ]); + assert_eq!( + a.tags, + vec!["pci".to_string(), "rotate-2026-q1".to_string()] + ); } #[test] @@ -323,13 +333,20 @@ mod tests { let raw = vec!["ok".to_string(), "bad tag".to_string()]; let err = validate_tags(&raw).unwrap_err(); let msg = err.to_string(); - assert!(msg.contains("bad tag"), "error mentions offending tag: {msg}"); + assert!( + msg.contains("bad tag"), + "error mentions offending tag: {msg}" + ); assert!(msg.contains("invalid tag"), "uses canonical prefix: {msg}"); } #[test] fn validate_tags_passes_through_valid_list() { - let raw = vec!["pci".to_string(), "team_backend".to_string(), "v1.2.3".to_string()]; + let raw = vec![ + "pci".to_string(), + "team_backend".to_string(), + "v1.2.3".to_string(), + ]; assert_eq!(validate_tags(&raw).unwrap(), raw); } } diff --git a/rust/src/cli/tag.rs b/rust/src/cli/tag.rs index 7349e53..e5c42d0 100644 --- a/rust/src/cli/tag.rs +++ b/rust/src/cli/tag.rs @@ -169,11 +169,7 @@ mod tests { apply_add(&mut tags, &["mu".to_string()]); assert_eq!( tags, - vec![ - "zeta".to_string(), - "alpha".to_string(), - "mu".to_string(), - ] + vec!["zeta".to_string(), "alpha".to_string(), "mu".to_string(),] ); } diff --git a/rust/src/config/env_cache.rs b/rust/src/config/env_cache.rs index a9d8e35..a035f85 100644 --- a/rust/src/config/env_cache.rs +++ b/rust/src/config/env_cache.rs @@ -324,9 +324,8 @@ fn read_entries(conn: &Connection, env_id: i64) -> Result> { }, "tag" => EnvEntry::Tag(value), "alias_tag" => EnvEntry::AliasTag { - key: alias_key.ok_or_else(|| { - HimitsuError::Index("alias_tag row missing alias_key".into()) - })?, + key: alias_key + .ok_or_else(|| HimitsuError::Index("alias_tag row missing alias_key".into()))?, tag: value, }, other => { diff --git a/rust/src/config/env_resolver.rs b/rust/src/config/env_resolver.rs index b755179..f6ae882 100644 --- a/rust/src/config/env_resolver.rs +++ b/rust/src/config/env_resolver.rs @@ -676,9 +676,7 @@ mod tests { /// paths fall back to "no tags" rather than erroring, mirroring the /// expected real-world behaviour for secrets that decrypt cleanly but /// happen to carry no `SecretValue.tags` field. - fn mk_tag_lookup( - map: BTreeMap>, - ) -> impl Fn(&str) -> Result> { + fn mk_tag_lookup(map: BTreeMap>) -> impl Fn(&str) -> Result> { move |path: &str| Ok(map.get(path).cloned().unwrap_or_default()) } @@ -687,18 +685,17 @@ mod tests { // `tag:pci` should pull in every secret carrying the tag, keyed // by last-path-segment. Non-pci secrets must be excluded. let e = envs(vec![("dev", vec![EnvEntry::Tag("pci".into())])]); - let secrets = strs(&[ - "dev/STRIPE_KEY", - "dev/POSTGRES_URL", - "extras/RATE_LIMITER", - ]); + let secrets = strs(&["dev/STRIPE_KEY", "dev/POSTGRES_URL", "extras/RATE_LIMITER"]); let mut tags = BTreeMap::new(); tags.insert("dev/STRIPE_KEY".to_string(), vec!["pci".to_string()]); tags.insert( "dev/POSTGRES_URL".to_string(), vec!["pci".to_string(), "rotate".to_string()], ); - tags.insert("extras/RATE_LIMITER".to_string(), vec!["mobile".to_string()]); + tags.insert( + "extras/RATE_LIMITER".to_string(), + vec!["mobile".to_string()], + ); let lookup = mk_tag_lookup(tags); let tree = resolve_with_tags(&e, "dev", &secrets, &lookup).unwrap(); @@ -809,7 +806,9 @@ mod tests { let e = envs(vec![("dev", vec![EnvEntry::Tag("pci".into())])]); let secrets = strs(&["dev/UNREADABLE"]); let failing = |_: &str| -> Result> { - Err(HimitsuError::DecryptionFailed("test: cannot decrypt".into())) + Err(HimitsuError::DecryptionFailed( + "test: cannot decrypt".into(), + )) }; let err = resolve_with_tags(&e, "dev", &secrets, &failing).unwrap_err(); diff --git a/rust/src/config/mod.rs b/rust/src/config/mod.rs index 57bf676..b28d35f 100644 --- a/rust/src/config/mod.rs +++ b/rust/src/config/mod.rs @@ -280,8 +280,7 @@ impl<'de> Deserialize<'de> for EnvEntry { // Map form `{ tag: pci }` — the literal key is `tag` and the // value is the tag name itself. if key == "tag" { - crate::crypto::tags::validate_tag(&value) - .map_err(serde::de::Error::custom)?; + crate::crypto::tags::validate_tag(&value).map_err(serde::de::Error::custom)?; return Ok(EnvEntry::Tag(value)); } // Map form `{ STRIPE: tag:stripe }` — alias whose value is diff --git a/rust/src/crypto/secret_value.rs b/rust/src/crypto/secret_value.rs index e47ec49..dda43b5 100644 --- a/rust/src/crypto/secret_value.rs +++ b/rust/src/crypto/secret_value.rs @@ -151,7 +151,6 @@ mod tests { assert!(!d.has_metadata()); } - #[test] fn annotations_round_trip() { let mut annotations = HashMap::new(); diff --git a/rust/src/main.rs b/rust/src/main.rs index 22a1d2f..cbc0abe 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -9,6 +9,7 @@ pub mod keyring; pub mod proto; pub mod reference; pub mod remote; +pub mod suggest; pub mod tui; use clap::Parser; diff --git a/rust/src/suggest.rs b/rust/src/suggest.rs new file mode 100644 index 0000000..5e7eb9f --- /dev/null +++ b/rust/src/suggest.rs @@ -0,0 +1,235 @@ +//! Levenshtein-based "did you mean" suggestion helpers. +//! +//! Shared by the `himitsu search` CLI (to print a suggestion when a query +//! returns zero hits) and the TUI autocomplete popup (which surfaces the +//! top-N closest secret paths as the user types a reference). +//! +//! Levenshtein is implemented inline against UTF-8 byte slices: a small DP +//! table is plenty for the single-millisecond budget search has, and pulling +//! in a fuzzy-match crate (`strsim` etc.) for one function is overkill. + +/// Case-sensitive byte-Levenshtein distance between two strings. +/// +/// Uses two rolling rows of size `b.len() + 1` so peak memory stays +/// `O(min(a, b))`. Operates on bytes, not Unicode codepoints — every existing +/// caller works with ASCII paths (`prod/api/STRIPE_KEY`) so paying the +/// cost of grapheme segmentation would be wasted. +pub fn levenshtein(a: &str, b: &str) -> usize { + if a == b { + return 0; + } + let a = a.as_bytes(); + let b = b.as_bytes(); + if a.is_empty() { + return b.len(); + } + if b.is_empty() { + return a.len(); + } + + let mut prev: Vec = (0..=b.len()).collect(); + let mut curr: Vec = vec![0; b.len() + 1]; + + for (i, ca) in a.iter().enumerate() { + curr[0] = i + 1; + for (j, cb) in b.iter().enumerate() { + let cost = if ca == cb { 0 } else { 1 }; + curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost); + } + std::mem::swap(&mut prev, &mut curr); + } + + prev[b.len()] +} + +/// Heuristic for the maximum "reasonable" edit distance for an `input`. +/// +/// Short queries (<6 chars) fall back to a hard floor of 2 so a 3-letter +/// typo doesn't go un-suggested. Longer inputs scale linearly at one third +/// of the input length, keeping suggestions tight on long paths where every +/// edit is expensive to type by accident. +pub fn default_max_distance(input: &str) -> usize { + (input.len() / 3).max(2) +} + +/// Closest single candidate to `input` within `max_distance`. +/// +/// Returns `None` when `candidates` is empty or the best match exceeds +/// `max_distance`. Ties are broken lexicographically so the result is +/// deterministic across runs. +pub fn suggest_closest<'a>( + input: &str, + candidates: &'a [String], + max_distance: usize, +) -> Option<&'a String> { + candidates + .iter() + .map(|c| (levenshtein(input, c), c)) + .filter(|(d, _)| *d <= max_distance) + .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1))) + .map(|(_, c)| c) +} + +/// Up to `n` closest candidates within `max_distance`, sorted by ascending +/// distance with lexicographic tie-break. +/// +/// Used by the TUI autocomplete popup to render a short ranked list as the +/// user types. Returns owned `String`s so the caller can stash the snapshot +/// without juggling lifetimes against the corpus. +pub fn suggest_top_n( + input: &str, + candidates: &[String], + n: usize, + max_distance: usize, +) -> Vec { + if n == 0 { + return Vec::new(); + } + let mut scored: Vec<(usize, &String)> = candidates + .iter() + .map(|c| (levenshtein(input, c), c)) + .filter(|(d, _)| *d <= max_distance) + .collect(); + scored.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1))); + scored.into_iter().take(n).map(|(_, c)| c.clone()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn corpus() -> Vec { + vec![ + "prod/api/STRIPE_KEY".to_string(), + "prod/api/GITHUB_TOKEN".to_string(), + "prod/db/POSTGRES_URL".to_string(), + "staging/api/STRIPE_KEY".to_string(), + ] + } + + #[test] + fn levenshtein_identical_is_zero() { + assert_eq!(levenshtein("abc", "abc"), 0); + assert_eq!(levenshtein("", ""), 0); + } + + #[test] + fn levenshtein_empty_input_returns_other_length() { + assert_eq!(levenshtein("", "abcd"), 4); + assert_eq!(levenshtein("abcd", ""), 4); + } + + #[test] + fn levenshtein_off_by_one_substitution() { + assert_eq!(levenshtein("kitten", "sitten"), 1); + } + + #[test] + fn levenshtein_off_by_one_insertion() { + assert_eq!(levenshtein("cat", "cats"), 1); + assert_eq!(levenshtein("cats", "cat"), 1); + } + + #[test] + fn levenshtein_transposition_costs_two_edits() { + // Standard Levenshtein (no Damerau) treats a transposition as + // two operations: delete + insert. + assert_eq!(levenshtein("ab", "ba"), 2); + } + + #[test] + fn levenshtein_classic_examples() { + assert_eq!(levenshtein("kitten", "sitting"), 3); + assert_eq!(levenshtein("flaw", "lawn"), 2); + } + + #[test] + fn levenshtein_is_case_sensitive() { + assert_eq!(levenshtein("ABC", "abc"), 3); + } + + #[test] + fn default_max_distance_floor_is_two() { + assert_eq!(default_max_distance(""), 2); + assert_eq!(default_max_distance("ab"), 2); + assert_eq!(default_max_distance("abcde"), 2); + } + + #[test] + fn default_max_distance_scales_with_length() { + assert_eq!(default_max_distance("abcdef"), 2); + assert_eq!(default_max_distance("abcdefghi"), 3); + assert_eq!(default_max_distance("a".repeat(30).as_str()), 10); + } + + #[test] + fn suggest_closest_returns_none_for_empty_corpus() { + let candidates: Vec = Vec::new(); + assert_eq!(suggest_closest("foo", &candidates, 2), None); + } + + #[test] + fn suggest_closest_picks_nearest_within_threshold() { + let candidates = corpus(); + // Typo: "stripe_kye" → close to "STRIPE_KEY" but case differs, so + // a case-sensitive query against the lower-cased basename works + // best. Use an upper-case typo to keep distance small. + let hit = suggest_closest("prod/api/STRIPE_KYE", &candidates, 4); + assert_eq!(hit.map(String::as_str), Some("prod/api/STRIPE_KEY")); + } + + #[test] + fn suggest_closest_returns_none_above_threshold() { + let candidates = corpus(); + let hit = suggest_closest("totally/different/path", &candidates, 2); + assert_eq!(hit, None); + } + + #[test] + fn suggest_closest_breaks_ties_lexicographically() { + // Both candidates are exactly one substitution from "ab"; lexicographic + // tie-break should prefer "ax" over "az". + let candidates = vec!["az".to_string(), "ax".to_string()]; + let hit = suggest_closest("ab", &candidates, 2); + assert_eq!(hit.map(String::as_str), Some("ax")); + } + + #[test] + fn suggest_top_n_returns_sorted_distance_lex() { + let candidates = vec![ + "alpha".to_string(), + "alpine".to_string(), + "alphas".to_string(), + "beta".to_string(), + ]; + let hits = suggest_top_n("alpha", &candidates, 3, 4); + // "alpha" is exact (0); "alphas" / "alpine" each cost ≥1. + assert_eq!(hits[0], "alpha"); + // Remaining two should be sorted by distance, then lexicographic. + assert!(hits.len() == 3); + assert!(hits.contains(&"alphas".to_string())); + assert!(hits.contains(&"alpine".to_string())); + assert!(!hits.contains(&"beta".to_string())); + } + + #[test] + fn suggest_top_n_caps_at_n() { + let candidates = corpus(); + let hits = suggest_top_n("prod/api/STRIPE_KEY", &candidates, 2, 50); + assert!(hits.len() <= 2); + } + + #[test] + fn suggest_top_n_with_zero_returns_empty() { + let candidates = corpus(); + let hits = suggest_top_n("prod", &candidates, 0, 50); + assert!(hits.is_empty()); + } + + #[test] + fn suggest_top_n_filters_outside_threshold() { + let candidates = vec!["totally_unrelated".to_string()]; + let hits = suggest_top_n("xyz", &candidates, 5, 2); + assert!(hits.is_empty()); + } +} diff --git a/rust/src/tui/app.rs b/rust/src/tui/app.rs index 879c8f7..9db4dff 100644 --- a/rust/src/tui/app.rs +++ b/rust/src/tui/app.rs @@ -14,6 +14,7 @@ use ratatui::widgets::{Block, Clear}; use ratatui::Frame; use crate::cli::Context; +pub use crate::tui::hint::Hint; use crate::tui::keymap::{Dispatch, KeyAction, KeyMap}; use crate::tui::theme; pub use crate::tui::toast::{Toast, ToastKind}; @@ -56,6 +57,11 @@ pub struct App { /// until [`Toast::is_expired`] returns true, at which point `draw` /// clears it. Non-modal: key events still flow to the current view. toast: Option, + /// Active ambient hint, if any. Painted in the bottom-left strip and + /// persists until the owning view replaces or clears it. Suppressed + /// while a toast is on screen so the two surfaces never fight for + /// attention — see `App::draw`. + hint: Option, /// Buffer of chord steps already pressed but not yet resolved. Set by /// [`KeyMap::dispatch`] returning [`Dispatch::Pending`]; cleared on the /// next match, abort, or non-chord keypress. @@ -78,6 +84,7 @@ impl App { keymap, help: None, toast: None, + hint: None, pending_chord: Vec::new(), chord_breadcrumb_active: false, } @@ -92,6 +99,19 @@ impl App { self.chord_breadcrumb_active = false; } + /// Publish an ambient bottom-left hint, replacing any previous one. + /// No expiry — callers must clear via [`App::clear_hint`] when the + /// context the hint described goes away (e.g. the view closes or + /// focus moves to a field with no associated tip). + pub fn set_hint(&mut self, msg: impl Into) { + self.hint = Some(Hint::new(msg)); + } + + /// Drop the active hint, if any. + pub fn clear_hint(&mut self) { + self.hint = None; + } + pub fn on_key(&mut self, key: KeyEvent) -> Option { // ── Help overlay intercept (US-012) ──────────────────────────── // If the overlay is open, route every key to it. Done before @@ -127,10 +147,7 @@ impl App { // their leader sequence didn't fire anything. let summary = format_pending(&self.pending_chord); self.pending_chord.clear(); - self.push_toast( - format!("chord aborted: {summary}"), - ToastKind::Info, - ); + self.push_toast(format!("chord aborted: {summary}"), ToastKind::Info); return None; } } @@ -218,6 +235,14 @@ impl App { } SearchAction::NewSecret => { self.view = View::NewSecret(NewSecretView::new(&self.ctx)); + // Seed the bottom-left hint with the tip for the initial + // focus (Path) so the user sees guidance immediately, + // before they press any keys. + if let Some(msg) = + NewSecretView::hint_for_step(crate::tui::views::new_secret::Step::Path) + { + self.set_hint(msg); + } } SearchAction::AddRemote => { self.view = View::RemoteAdd(RemoteAddView::new(&self.ctx)); @@ -329,16 +354,21 @@ impl App { NewSecretAction::Quit => self.should_quit = true, NewSecretAction::Cancel => { self.view = View::Search(SearchView::new(&self.ctx)); + self.clear_hint(); self.push_toast("create cancelled", ToastKind::Info); } NewSecretAction::Created(path) => { self.view = View::Search(SearchView::new(&self.ctx)); + self.clear_hint(); self.push_toast(format!("created {path}"), ToastKind::Success); } NewSecretAction::Failed(err) => { self.view = View::Search(SearchView::new(&self.ctx)); + self.clear_hint(); self.push_toast(format!("create failed: {err}"), ToastKind::Error); } + NewSecretAction::SetHint(msg) => self.set_hint(msg), + NewSecretAction::ClearHint => self.clear_hint(), } None } @@ -396,6 +426,12 @@ impl App { self.toast.as_ref() } + /// Borrow the active hint for integration-test assertions. + #[cfg(test)] + pub fn hint(&self) -> Option<&Hint> { + self.hint.as_ref() + } + /// Force-expire the active toast by rewinding `expires_at` to the /// current instant. The next `draw` call will then sweep it away, so /// tests can simulate "3 seconds later" without any real sleep. @@ -470,6 +506,21 @@ impl App { frame.render_widget(Clear, strip); t.render(frame, strip); } + } else if let Some(h) = self.hint.as_ref() { + // Hint is suppressed whenever a toast is active so the two + // bottom-row surfaces never fight for the same cells. Toasts + // are transient (3s TTL); the hint reappears as soon as the + // toast clears. + let area = frame.area(); + if area.height > 0 { + let strip = Rect { + x: area.x, + y: area.y + area.height - 1, + width: area.width, + height: 1, + }; + h.render(frame, strip); + } } // Help overlay is drawn last so it paints over the underlying view. if let Some(help) = self.help.as_ref() { diff --git a/rust/src/tui/forms/mod.rs b/rust/src/tui/forms/mod.rs index d88db54..5a5f31c 100644 --- a/rust/src/tui/forms/mod.rs +++ b/rust/src/tui/forms/mod.rs @@ -57,6 +57,10 @@ pub struct Field { pub required: bool, pub value: String, pub validator: Option, + /// Hint shown in muted style when the field is empty AND unfocused. + /// Cleared as soon as the user types or focuses the field — it is a + /// suggestion, not a default. + pub placeholder: Option<&'static str>, } impl Field { @@ -71,6 +75,7 @@ impl Field { required: false, value: String::new(), validator: None, + placeholder: None, } } @@ -84,6 +89,11 @@ impl Field { self } + pub fn placeholder(mut self, p: &'static str) -> Self { + self.placeholder = Some(p); + self + } + fn validate(&self) -> Result<(), String> { if self.required && self.value.trim().is_empty() { return Err(format!("{} is required", self.label)); @@ -351,11 +361,26 @@ impl FormView { } else { Style::default().fg(theme::muted()) }); - let mut text = field.value.clone(); - if focused { - text.push('_'); - } - let para = Paragraph::new(text).block(block); + // Empty + unfocused renders the placeholder hint in muted style. + // Focused always shows the buffer with a trailing cursor — typing + // immediately replaces the hint, so we don't show both. + let para = if !focused && field.value.is_empty() { + if let Some(ph) = field.placeholder { + Paragraph::new(Line::from(Span::styled( + ph, + Style::default().fg(theme::muted()), + ))) + .block(block) + } else { + Paragraph::new(String::new()).block(block) + } + } else { + let mut text = field.value.clone(); + if focused { + text.push('_'); + } + Paragraph::new(text).block(block) + }; match field.widget { Widget::Text => frame.render_widget(para, area), Widget::TextArea => frame.render_widget(para.wrap(Wrap { trim: false }), area), @@ -542,6 +567,7 @@ mod tests { required: false, value: String::new(), validator: None, + placeholder: None, }], ); typ(&mut f, &km, "line1"); @@ -577,6 +603,61 @@ mod tests { assert_eq!(f.fields()[0].label, "Slug"); } + // ── Placeholder rendering ────────────────────────────────────────── + + fn render_field(form: &FormView, focused: bool, field_idx: usize) -> String { + use ratatui::backend::TestBackend; + use ratatui::Terminal; + let mut terminal = Terminal::new(TestBackend::new(40, 3)).unwrap(); + let area = Rect::new(0, 0, 40, 3); + terminal + .draw(|frame| form.draw_field(frame, area, &form.fields[field_idx], focused)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn placeholder_renders_when_empty_and_unfocused() { + let form = FormView::new( + "test", + vec![Field::text("a", "A", "").placeholder("e.g. hint")], + ); + let rendered = render_field(&form, false, 0); + assert!(rendered.contains("e.g. hint")); + assert!(!rendered.contains('_')); + } + + #[test] + fn placeholder_hidden_when_focused() { + let form = FormView::new( + "test", + vec![Field::text("a", "A", "").placeholder("e.g. hint")], + ); + let rendered = render_field(&form, true, 0); + assert!(!rendered.contains("e.g. hint")); + assert!(rendered.contains('_')); + } + + #[test] + fn placeholder_hidden_when_value_present() { + let mut form = FormView::new( + "test", + vec![Field::text("a", "A", "").placeholder("e.g. hint")], + ); + form.fields[0].value = "typed".to_string(); + let rendered = render_field(&form, false, 0); + assert!(!rendered.contains("e.g. hint")); + assert!(rendered.contains("typed")); + } + #[test] fn from_form_round_trips_through_proto_form() { let km = KeyMap::default(); diff --git a/rust/src/tui/harness.rs b/rust/src/tui/harness.rs index 778233b..41c9d0b 100644 --- a/rust/src/tui/harness.rs +++ b/rust/src/tui/harness.rs @@ -347,6 +347,64 @@ mod tests { ); } + // ── hm-isi: ambient bottom-left hint follows field focus ───────── + // + // Opening the new-secret form should seed the bottom-left hint with the + // tip for the path field, then swap to the tags / expires_at tip as the + // user tabs through, and clear the hint when the form closes (Esc / + // Cancel). The hint sits in the left third of the bottom strip. + + #[test] + fn ambient_hint_tracks_focus_and_clears_on_cancel() { + let fx = Fixture::new(); + let mut h = TuiHarness::new(&fx.ctx); + + // No hint on the search view by default. + assert!(h.app.hint().is_none(), "search view should not set a hint"); + + h.press_ctrl('n'); + assert_eq!(h.app.current_view(), "new_secret"); + let hint = h + .app + .hint() + .expect("opening new-secret should seed the path hint"); + assert!( + hint.message.contains("slashes"), + "expected slashes tip on path step, got {:?}", + hint.message + ); + + // Tab forward through Path → Value → Description → Tags. Tags has + // its own hint so the router should swap. + h.type_str("prod/KEY"); + h.press(KeyCode::Tab); // value + h.type_str("v"); + h.press(KeyCode::Tab); // description + h.press(KeyCode::Tab); // tags + let hint = h.app.hint().expect("tags step should swap in its hint"); + assert!( + hint.message.contains("comma-separated"), + "expected tags tip, got {:?}", + hint.message + ); + + // Cancelling the form pushes a toast; toast visually suppresses + // the hint, but the underlying hint state should also be cleared + // so the search view starts hint-free. + h.press(KeyCode::Esc); + // The first Esc opens the unsaved-changes modal (path field has + // content). Confirm-button cycles to Discard (Right twice past + // KeepEditing → Save → Discard) then Enter discards. + h.press(KeyCode::Right); + h.press(KeyCode::Right); + h.press(KeyCode::Enter); + assert_eq!(h.app.current_view(), "search"); + assert!( + h.app.hint().is_none(), + "hint should be cleared after cancelling new-secret" + ); + } + // ── Flow 4: Ctrl+Y in search → toast visible in rendered buffer ── // // Exercises the global toast surface (hm-o15). On headless CI the diff --git a/rust/src/tui/hint.rs b/rust/src/tui/hint.rs new file mode 100644 index 0000000..bc4b912 --- /dev/null +++ b/rust/src/tui/hint.rs @@ -0,0 +1,211 @@ +//! Ambient bottom-left hint strip (hm-isi). +//! +//! A [`Hint`] is a low-stakes, persistent piece of guidance text — the TUI +//! equivalent of a Vim modeline tip or an IDE status hint. Unlike a +//! [`crate::tui::toast::Toast`], a hint: +//! +//! - has no expiry (it persists until the owning view replaces or clears it), +//! - has no severity / colour / icon variants — just one muted, dimmed style, +//! - paints only the **left third** of the bottom strip so a toast can still +//! own the right-aligned portion if both are simultaneously active. +//! +//! Toasts already cover transient feedback ("saved", "copied", "deleted"); the +//! hint lane is reserved for ambient context that helps the user understand +//! what the focused field expects. + +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use super::theme; + +/// Hard cap on rendered hint length, in **characters** (not bytes). Anything +/// past this limit is truncated with a trailing `…` so the hint never spills +/// past its left-third strip even on narrow terminals. +pub const MAX_LEN: usize = 60; + +/// A persistent ambient hint shown in the bottom-left of the frame. +/// +/// Construction is intentionally trivial — there is no state besides the +/// message itself, because the hint lifecycle is owned by whichever view set +/// it (the view clears or replaces the hint when its focus changes). +#[derive(Debug, Clone)] +pub struct Hint { + pub message: String, +} + +impl Hint { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + /// Render this hint into the **left third** of `area`, leaving the rest + /// of the strip untouched so a coexisting toast can paint over the right + /// portion. Caller is responsible for reserving the row via [`Layout`]; + /// this function never calls [`ratatui::widgets::Clear`]. + pub fn render(&self, frame: &mut Frame<'_>, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + let strip_width = (area.width / 3).max(1); + let strip = Rect { + x: area.x, + y: area.y, + width: strip_width, + height: 1, + }; + + let style = Style::default() + .fg(theme::muted()) + .add_modifier(Modifier::DIM); + // The whole line (message and the leading marker) shares the same + // muted/dim style — there is no severity differentiation, that's the + // toast's job. + let line = Line::from(vec![Span::styled( + truncate_for_render(&self.message), + style, + )]); + frame.render_widget(Paragraph::new(line), strip); + } +} + +/// Truncate `message` to at most [`MAX_LEN`] characters, appending an ellipsis +/// when truncation occurred. Operates on `chars` (not bytes) so multi-byte +/// codepoints can never be sliced mid-character. +fn truncate_for_render(message: &str) -> String { + let char_count = message.chars().count(); + if char_count <= MAX_LEN { + return message.to_string(); + } + // Reserve one char of budget for the trailing `…` so the rendered string + // is at most MAX_LEN characters wide. + let mut out: String = message.chars().take(MAX_LEN.saturating_sub(1)).collect(); + out.push('…'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn untruncated_when_shorter_than_limit() { + let s = "short hint"; + assert_eq!(truncate_for_render(s), s); + } + + #[test] + fn untruncated_at_exactly_max_len() { + let s = "a".repeat(MAX_LEN); + let rendered = truncate_for_render(&s); + assert_eq!(rendered.chars().count(), MAX_LEN); + assert_eq!(rendered, s, "exactly MAX_LEN should not be truncated"); + } + + #[test] + fn truncated_with_ellipsis_when_one_over_limit() { + let s = "a".repeat(MAX_LEN + 1); + let rendered = truncate_for_render(&s); + assert_eq!(rendered.chars().count(), MAX_LEN); + assert!( + rendered.ends_with('…'), + "truncated hint should end with ellipsis, got {rendered:?}" + ); + } + + #[test] + fn truncated_with_ellipsis_when_far_over_limit() { + let s = "b".repeat(MAX_LEN * 4); + let rendered = truncate_for_render(&s); + assert_eq!(rendered.chars().count(), MAX_LEN); + assert!(rendered.ends_with('…')); + } + + #[test] + fn multibyte_boundary_safe() { + // A long string of 4-byte codepoints would panic with byte slicing + // but must be cleanly truncated when slicing on chars. + let s = "🦀".repeat(MAX_LEN + 5); + let rendered = truncate_for_render(&s); + assert_eq!(rendered.chars().count(), MAX_LEN); + assert!(rendered.ends_with('…')); + // Round-trip — ensure we produced valid UTF-8 (would have panicked + // already if not, but keep an explicit assertion). + assert!(rendered.is_char_boundary(rendered.len())); + } + + #[test] + fn multibyte_message_under_limit_unchanged() { + let s = "· tip: 日本語 hello"; + assert_eq!(truncate_for_render(s), s); + } + + #[test] + fn render_into_zero_width_area_is_a_noop() { + // We only need to verify it doesn't panic — the buffer is unchanged + // because `render` returns early for zero-width areas. + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + let backend = TestBackend::new(20, 3); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + let h = Hint::new("· tip: anything"); + h.render( + frame, + Rect { + x: 0, + y: 0, + width: 0, + height: 1, + }, + ); + }) + .unwrap(); + } + + #[test] + fn render_paints_only_left_third_of_strip() { + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + let backend = TestBackend::new(30, 1); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + let h = Hint::new("hint!"); + h.render( + frame, + Rect { + x: 0, + y: 0, + width: 30, + height: 1, + }, + ); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + // Left third = 10 cells. The hint text "hint!" should land in the + // first 5 cells; cells beyond the strip width must remain blank so + // a coexisting toast can claim them. + let mut left = String::new(); + for x in 0..10 { + left.push_str(buf[(x, 0)].symbol()); + } + assert!(left.starts_with("hint!"), "left strip = {left:?}"); + for x in 10..30 { + assert_eq!( + buf[(x, 0)].symbol(), + " ", + "cell {x} should be untouched by hint render" + ); + } + } +} diff --git a/rust/src/tui/keymap.rs b/rust/src/tui/keymap.rs index ca95412..82b3bfc 100644 --- a/rust/src/tui/keymap.rs +++ b/rust/src/tui/keymap.rs @@ -667,11 +667,7 @@ impl KeyMap { /// priority slice (e.g. the secret viewer wants `Rekey` before /// `Reveal` so `Shift+R` doesn't fall through to bare `r`); shared /// here so the per-view helpers don't each rebuild the same iteration. - pub fn action_for_key_in( - &self, - key: &KeyEvent, - priority: &[KeyAction], - ) -> Option { + pub fn action_for_key_in(&self, key: &KeyEvent, priority: &[KeyAction]) -> Option { priority .iter() .copied() diff --git a/rust/src/tui/mod.rs b/rust/src/tui/mod.rs index c5086c4..8b82356 100644 --- a/rust/src/tui/mod.rs +++ b/rust/src/tui/mod.rs @@ -11,12 +11,14 @@ mod event; pub mod forms; #[cfg(test)] mod harness; +mod hint; mod icons; pub mod keymap; mod terminal; mod theme; mod toast; mod views; +pub mod widgets; use std::path::PathBuf; use std::time::Duration; diff --git a/rust/src/tui/views/command_palette.rs b/rust/src/tui/views/command_palette.rs index 4d320f7..5fc0c28 100644 --- a/rust/src/tui/views/command_palette.rs +++ b/rust/src/tui/views/command_palette.rs @@ -496,7 +496,7 @@ mod tests { } assert!(p.filtered.contains(&Command::AddRemote)); // "remote" also matches "add remote" — just confirm it's in the filtered set. - assert!(p.filtered.iter().any(|c| *c == Command::AddRemote)); + assert!(p.filtered.contains(&Command::AddRemote)); } #[test] diff --git a/rust/src/tui/views/envs.rs b/rust/src/tui/views/envs.rs index 40c7f05..eec4fe6 100644 --- a/rust/src/tui/views/envs.rs +++ b/rust/src/tui/views/envs.rs @@ -188,7 +188,9 @@ impl CreateEditor { // TUI editor predates them. Fall back to the DSL editor by // refusing this single-entry shortcut. EnvEntry::Tag(_) | EnvEntry::AliasTag { .. } => { - return Err("edit not yet supported for `tag:` selectors — use the DSL editor".into()); + return Err( + "edit not yet supported for `tag:` selectors — use the DSL editor".into(), + ); } } diff --git a/rust/src/tui/views/new_secret.rs b/rust/src/tui/views/new_secret.rs index 4de7c70..e1ccc94 100644 --- a/rust/src/tui/views/new_secret.rs +++ b/rust/src/tui/views/new_secret.rs @@ -27,14 +27,14 @@ //! correct the input. use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use super::standard_canvas; use crate::tui::theme; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use ratatui::Frame; use crate::cli::duration::{self, ExpiresAt}; @@ -70,6 +70,13 @@ pub enum NewSecretAction { /// Submission failed but the form should stay open so the user can /// edit. Carries the error message to show in the status line. Failed(String), + /// Ask the router to publish an ambient bottom-left hint. Routed + /// through the action enum (rather than reaching into the App + /// directly) to mirror the existing pattern used for toasts. + SetHint(String), + /// Ask the router to clear the ambient hint, e.g. when focus leaves + /// a field that had a tip pinned. + ClearHint, } /// Which field currently has focus. @@ -83,12 +90,15 @@ pub enum Step { Totp, EnvKey, ExpiresAt, + /// Final tab stop — a submit-button row. Pressing Enter here triggers + /// the same `submit()` path as Ctrl+S/Ctrl+W. + Submit, } impl Step { /// All steps in display/tab order. Used for Tab / Shift-Tab cycling /// and for tests asserting the cycle visits every field. - const ORDER: [Step; 8] = [ + const ORDER: [Step; 9] = [ Step::Path, Step::Value, Step::Description, @@ -97,6 +107,7 @@ impl Step { Step::Totp, Step::EnvKey, Step::ExpiresAt, + Step::Submit, ]; fn index(self) -> usize { @@ -117,6 +128,46 @@ impl Step { } } +/// Buttons in the unsaved-changes confirm dialog. Drawn left-to-right in the +/// declared order; `KeepEditing` is the safe default focus when the modal +/// opens (a stray Esc + Enter shouldn't lose work). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfirmButton { + KeepEditing, + Save, + Discard, +} + +impl ConfirmButton { + const ORDER: [ConfirmButton; 3] = [ + ConfirmButton::KeepEditing, + ConfirmButton::Save, + ConfirmButton::Discard, + ]; + + fn index(self) -> usize { + Self::ORDER.iter().position(|b| *b == self).unwrap() + } + + fn next(self) -> Self { + let i = self.index(); + Self::ORDER[(i + 1) % Self::ORDER.len()] + } + + fn prev(self) -> Self { + let i = self.index(); + Self::ORDER[(i + Self::ORDER.len() - 1) % Self::ORDER.len()] + } + + fn label(self) -> &'static str { + match self { + ConfirmButton::KeepEditing => "[ keep editing ]", + ConfirmButton::Save => "[ save ]", + ConfirmButton::Discard => "[ discard ]", + } + } +} + pub struct NewSecretView { step: Step, path: String, @@ -128,6 +179,9 @@ pub struct NewSecretView { env_key: String, expires_at: String, status: Option, + /// `Some(focused_button)` while the unsaved-changes modal is up. + /// `None` means the modal is closed and the form behaves normally. + confirm_exit: Option, ctx: Context, } @@ -144,10 +198,29 @@ impl NewSecretView { env_key: String::new(), expires_at: String::new(), status: None, + confirm_exit: None, ctx: ctx.clone(), } } + #[cfg(test)] + pub fn confirm_exit(&self) -> Option { + self.confirm_exit + } + + /// `true` if any field carries content the user might want to save. + /// Used to decide whether Esc opens the confirm dialog or just bails. + fn has_unsaved_changes(&self) -> bool { + !self.path.is_empty() + || !self.value.is_empty() + || !self.description.is_empty() + || !self.tags.is_empty() + || !self.url.is_empty() + || !self.totp.is_empty() + || !self.env_key.is_empty() + || !self.expires_at.is_empty() + } + #[cfg(test)] pub fn step(&self) -> Step { self.step @@ -170,7 +243,8 @@ impl NewSecretView { /// Mutable accessor to the buffer that backs the currently focused step. /// `Value` is multi-line so it lives in its own helper; every other field - /// routes through this single-line path. + /// routes through this single-line path. The `Submit` step has no + /// buffer — its handler is keyboard-only. fn field_buffer_mut(&mut self, step: Step) -> Option<&mut String> { match step { Step::Path => Some(&mut self.path), @@ -181,6 +255,7 @@ impl NewSecretView { Step::Totp => Some(&mut self.totp), Step::EnvKey => Some(&mut self.env_key), Step::ExpiresAt => Some(&mut self.expires_at), + Step::Submit => None, } } @@ -195,29 +270,88 @@ impl NewSecretView { ) { return NewSecretAction::Quit; } + + // Confirm dialog swallows everything else while active. + if self.confirm_exit.is_some() { + return self.handle_confirm_exit_key(key); + } + + // Snapshot the step before key dispatch so we can detect step + // transitions and emit a hint action without any per-field handler + // having to know about the bottom-left strip. + let before = self.step; + // Resolve cancel / save / prev_field up front so a chord-completed // action takes the same path as the bare keystroke. NextField // stays inside the field-specific handlers because it interacts - // with per-field validation. + // with per-field validation. Note: outcomes from `dispatch_action` + // are forwarded untouched — submission/save can refocus fields + // internally (e.g. snap back to Path on validation error), and + // we don't want that internal step churn to clobber the action. if let Some(action) = keymap.action_for_key_in(&key, FORM_ACTION_PRIORITY) { if let Some(outcome) = self.dispatch_action(action) { + // Only `PrevField` is purely navigational; map its `None` + // outcome to a hint sync if the step actually moved. + if matches!(action, KeyAction::PrevField) { + return self.maybe_swap_for_hint(outcome, before); + } return outcome; } } - match self.step { + let inner = match self.step { Step::Value => self.handle_value_key(key, keymap), + Step::Submit => self.handle_submit_step_key(key, keymap), _ => self.handle_single_line_key(key, keymap), + }; + self.maybe_swap_for_hint(inner, before) + } + + /// Replace `inner` with a `SetHint`/`ClearHint` action when the step + /// changed and the inner action carried no other meaning (i.e. `None`). + /// Submission/cancellation outcomes are forwarded untouched so a hint + /// transition never masks a meaningful router action. + fn maybe_swap_for_hint(&self, inner: NewSecretAction, before: Step) -> NewSecretAction { + if !matches!(inner, NewSecretAction::None) || before == self.step { + return inner; + } + match Self::hint_for_step(self.step) { + Some(msg) => NewSecretAction::SetHint(msg.to_string()), + None => NewSecretAction::ClearHint, + } + } + + /// Hint message associated with each step. `None` means the step has + /// no ambient tip — the router should clear any leftover hint. + pub(crate) fn hint_for_step(step: Step) -> Option<&'static str> { + match step { + // Slashes turn into folder headers in the search view, so the + // tip nudges users toward the same convention used elsewhere. + Step::Path => Some("· tip: use slashes (prod/api/KEY) for grouping"), + // Commas separate tags and the grammar forbids whitespace. + Step::Tags => Some("· tip: comma-separated, no spaces"), + // RFC 3339 + relative durations + `never` is a lot to remember. + Step::ExpiresAt => Some("· tip: never, 30d, 6mo, 1y, or RFC 3339"), + _ => None, } } /// Run a [`KeyAction`] against the new-secret form. Returns `None` for /// actions this form doesn't own (e.g. NextField, which is intentionally /// scoped to the field-specific handlers below so it interacts with the - /// per-field validate-then-advance flow). + /// per-field validate-then-advance flow). Cancel routes through the + /// confirm-exit gate so a stray Esc never silently throws away typed + /// input. pub fn dispatch_action(&mut self, action: KeyAction) -> Option { match action { - KeyAction::Cancel => Some(NewSecretAction::Cancel), + KeyAction::Cancel => { + if self.has_unsaved_changes() { + self.confirm_exit = Some(ConfirmButton::KeepEditing); + Some(NewSecretAction::None) + } else { + Some(NewSecretAction::Cancel) + } + } KeyAction::SaveSecret => Some(self.submit()), KeyAction::PrevField => { self.move_to(self.step.prev()); @@ -227,6 +361,66 @@ impl NewSecretView { } } + /// Submit-step keys: Enter triggers submission; Up/Tab/Down navigate; + /// every other key is ignored (no buffer to type into). + fn handle_submit_step_key(&mut self, key: KeyEvent, keymap: &KeyMap) -> NewSecretAction { + if keymap.next_field.matches(&key) { + self.move_to(self.step.next()); + return NewSecretAction::None; + } + match (key.code, key.modifiers) { + (KeyCode::Enter, _) => self.submit(), + (KeyCode::Up, _) => { + self.move_to(self.step.prev()); + NewSecretAction::None + } + (KeyCode::Down, _) => { + self.move_to(self.step.next()); + NewSecretAction::None + } + _ => NewSecretAction::None, + } + } + + /// Modal key handler for the unsaved-changes confirm dialog. Left/Right + /// (and Tab) cycle the focused button, Enter activates it, Esc resolves + /// to the safe option (`keep editing`) — i.e. dismiss the modal without + /// taking action. + fn handle_confirm_exit_key(&mut self, key: KeyEvent) -> NewSecretAction { + let Some(focused) = self.confirm_exit else { + return NewSecretAction::None; + }; + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => { + self.confirm_exit = None; + NewSecretAction::None + } + (KeyCode::Left, _) => { + self.confirm_exit = Some(focused.prev()); + NewSecretAction::None + } + (KeyCode::Right, _) | (KeyCode::Tab, _) => { + self.confirm_exit = Some(focused.next()); + NewSecretAction::None + } + (KeyCode::Enter, _) => match focused { + ConfirmButton::Save => { + self.confirm_exit = None; + self.submit() + } + ConfirmButton::Discard => { + self.confirm_exit = None; + NewSecretAction::Cancel + } + ConfirmButton::KeepEditing => { + self.confirm_exit = None; + NewSecretAction::None + } + }, + _ => NewSecretAction::None, + } + } + /// Single-line editor used by every field except `Value`. `Tab` / `Enter` /// advances to the next field (running field-local validation first); /// `Backspace` erases; printable chars append. @@ -296,9 +490,7 @@ impl NewSecretView { /// printable char and defers validation to leave/submit time. fn accepts_char(step: Step, c: char) -> bool { match step { - Step::Tags => { - c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' || c == ',' - } + Step::Tags => c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' || c == ',', _ => true, } } @@ -368,6 +560,7 @@ impl NewSecretView { .map(|_| ()) .map_err(|e| format!("{e}")) } + Step::Submit => Ok(()), } } @@ -499,12 +692,20 @@ impl NewSecretView { Constraint::Length(3), // totp Constraint::Length(3), // env_key Constraint::Length(3), // expires_at + Constraint::Length(3), // submit button Constraint::Length(1), // footer ]) .split(area); self.draw_header(frame, chunks[0]); - self.draw_single_line(frame, chunks[1], Step::Path, " path ", &self.path); + self.draw_single_line( + frame, + chunks[1], + Step::Path, + " path ", + &self.path, + "prod/api/STRIPE_KEY", + ); self.draw_value_field(frame, chunks[2]); self.draw_single_line( frame, @@ -512,19 +713,55 @@ impl NewSecretView { Step::Description, " description ", &self.description, + "human-readable note (optional)", + ); + self.draw_single_line( + frame, + chunks[4], + Step::Tags, + " tags ", + &self.tags, + "comma-separated, e.g. pci,stripe", + ); + self.draw_single_line( + frame, + chunks[5], + Step::Url, + " url ", + &self.url, + "https://example.com", + ); + self.draw_single_line( + frame, + chunks[6], + Step::Totp, + " totp ", + &self.totp, + "otpauth://... or base32 secret", + ); + self.draw_single_line( + frame, + chunks[7], + Step::EnvKey, + " env_key ", + &self.env_key, + "STRIPE_KEY", ); - self.draw_tags_field(frame, chunks[4]); - self.draw_single_line(frame, chunks[5], Step::Url, " url ", &self.url); - self.draw_single_line(frame, chunks[6], Step::Totp, " totp ", &self.totp); - self.draw_single_line(frame, chunks[7], Step::EnvKey, " env_key ", &self.env_key); self.draw_single_line( frame, chunks[8], Step::ExpiresAt, " expires_at ", &self.expires_at, + "never | 30d | 6mo | 2027-01-01T00:00:00Z", ); - self.draw_footer(frame, chunks[9]); + self.draw_submit_button(frame, chunks[9]); + self.draw_footer(frame, chunks[10]); + + // Modal overlay paints last so it sits above the form. + if let Some(focused) = self.confirm_exit { + self.draw_confirm_exit(frame, focused); + } } fn draw_header(&self, frame: &mut Frame<'_>, area: Rect) { @@ -544,6 +781,7 @@ impl NewSecretView { step: Step, title: &str, content: &str, + placeholder: &str, ) { let focused = self.step == step; let block = Block::default() @@ -551,54 +789,41 @@ impl NewSecretView { .title(title.to_string()) .title_style(Style::default().fg(theme::border_label())) .border_style(Self::border_style(focused)); - let mut text = content.to_string(); - if focused { - text.push('_'); - } - frame.render_widget(Paragraph::new(text).block(block), area); + let para = Self::field_paragraph(content, placeholder, focused).block(block); + frame.render_widget(para, area); } - /// Render the `Tags` step. When the buffer is empty and unfocused we - /// surface a muted hint inside the input so the user sees the - /// comma-separated DSL without having to read external help text. - fn draw_tags_field(&self, frame: &mut Frame<'_>, area: Rect) { - let focused = self.step == Step::Tags; + fn draw_value_field(&self, frame: &mut Frame<'_>, area: Rect) { + let focused = self.step == Step::Value; let block = Block::default() .borders(Borders::ALL) - .title(" tags ") + .title(" value ") .title_style(Style::default().fg(theme::border_label())) .border_style(Self::border_style(focused)); - let para = if self.tags.is_empty() && !focused { - Paragraph::new(Span::styled( - "comma-separated, e.g. pci,stripe", + let para = + Self::field_paragraph(&self.value, "value here — Enter inserts a newline", focused) + .block(block) + .wrap(Wrap { trim: false }); + frame.render_widget(para, area); + } + + /// Build the paragraph body shared by every field: muted placeholder + /// when empty + unfocused, otherwise the buffer with a trailing cursor + /// while focused. Centralised so the placeholder behaviour is uniform + /// across single-line and multi-line inputs. + fn field_paragraph<'a>(content: &'a str, placeholder: &'a str, focused: bool) -> Paragraph<'a> { + if !focused && content.is_empty() { + Paragraph::new(Line::from(Span::styled( + placeholder, Style::default().fg(theme::muted()), - )) - .block(block) + ))) } else { - let mut text = self.tags.clone(); + let mut text = content.to_string(); if focused { text.push('_'); } - Paragraph::new(text).block(block) - }; - frame.render_widget(para, area); - } - - fn draw_value_field(&self, frame: &mut Frame<'_>, area: Rect) { - let focused = self.step == Step::Value; - let block = Block::default() - .borders(Borders::ALL) - .title(" value ") - .title_style(Style::default().fg(theme::border_label())) - .border_style(Self::border_style(focused)); - let mut text = self.value.clone(); - if focused { - text.push('_'); + Paragraph::new(text) } - frame.render_widget( - Paragraph::new(text).block(block).wrap(Wrap { trim: false }), - area, - ); } fn border_style(focused: bool) -> Style { @@ -609,6 +834,87 @@ impl NewSecretView { } } + /// Submit-button row. Same 3-row footprint as a text field so the layout + /// above stays stable; only the contents and border colour change with + /// focus. + fn draw_submit_button(&self, frame: &mut Frame<'_>, area: Rect) { + let focused = self.step == Step::Submit; + let block = Block::default() + .borders(Borders::ALL) + .border_style(Self::border_style(focused)); + let label_style = if focused { + Style::default() + .fg(theme::accent()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::muted()) + }; + let para = Paragraph::new(Line::from(Span::styled("[ submit ]", label_style))) + .alignment(Alignment::Center) + .block(block); + frame.render_widget(para, area); + } + + /// Centered "unsaved changes" popup. Painted on top of the form when the + /// user presses Esc with at least one populated field. + fn draw_confirm_exit(&self, frame: &mut Frame<'_>, focused: ConfirmButton) { + let area = confirm_popup_rect(frame.area()); + frame.render_widget(Clear, area); + + let block = Block::default() + .borders(Borders::ALL) + .title(Span::styled( + " unsaved changes ", + Style::default() + .fg(theme::border_label()) + .add_modifier(Modifier::BOLD), + )) + .border_style(Style::default().fg(theme::accent())); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // top pad + Constraint::Length(1), // body + Constraint::Length(1), // pad + Constraint::Length(1), // buttons + Constraint::Min(0), + ]) + .split(inner); + + let body = Paragraph::new(Line::from(Span::styled( + "save the new secret, discard, or keep editing?", + Style::default().fg(theme::footer_text()), + ))) + .alignment(Alignment::Center); + frame.render_widget(body, rows[1]); + + let buttons: Vec> = ConfirmButton::ORDER + .iter() + .enumerate() + .flat_map(|(i, b)| { + let style = if *b == focused { + Style::default() + .fg(theme::accent()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::muted()) + }; + let mut spans = vec![Span::styled(b.label(), style)]; + if i + 1 < ConfirmButton::ORDER.len() { + spans.push(Span::raw(" ")); + } + spans + }) + .collect(); + frame.render_widget( + Paragraph::new(Line::from(buttons)).alignment(Alignment::Center), + rows[3], + ); + } + fn draw_footer(&self, frame: &mut Frame<'_>, area: Rect) { let line = if let Some(msg) = &self.status { Line::from(Span::styled( @@ -635,11 +941,16 @@ impl NewSecretView { pub fn help_entries() -> &'static [(&'static str, &'static str)] { &[ - ("tab / enter", "next field (wraps)"), + ( + "tab / enter", + "next field (wraps); tab cycles into [ submit ]", + ), ("shift-tab", "previous field (wraps)"), ("enter (value)", "insert newline"), + ("enter (submit)", "save the new secret"), ("ctrl-s / ctrl-w", "save from any field"), - ("esc / ctrl-c", "cancel"), + ("esc", "cancel (prompts if any field has content)"), + ("ctrl-c", "quit"), ("?", "toggle this help"), ] } @@ -649,6 +960,21 @@ impl NewSecretView { } } +/// Centered rect for the unsaved-changes popup. ~50 columns × 7 rows keeps +/// the dialog readable on small terminals while staying compact. +fn confirm_popup_rect(area: Rect) -> Rect { + const W: u16 = 50; + const H: u16 = 7; + let width = W.min(area.width); + let height = H.min(area.height); + Rect { + x: area.x + (area.width.saturating_sub(width) / 2), + y: area.y + (area.height.saturating_sub(height) / 2), + width, + height, + } +} + /// Parse the raw "comma-separated tags" buffer into a validated list. /// /// Splits on `,`, trims whitespace around each piece, drops empties, and @@ -761,14 +1087,26 @@ mod tests { } #[test] - fn esc_cancels_the_form() { + fn esc_on_empty_form_cancels_immediately() { let km = KeyMap::default(); let mut view = NewSecretView::new(&empty_ctx()); - typ(&mut view, "x"); + // No fields populated → no point prompting. assert!(matches!( view.on_key(press(KeyCode::Esc), &km), NewSecretAction::Cancel )); + assert!(view.confirm_exit().is_none()); + } + + #[test] + fn esc_with_unsaved_changes_opens_confirm_dialog() { + let km = KeyMap::default(); + let mut view = NewSecretView::new(&empty_ctx()); + typ(&mut view, "x"); + let out = view.on_key(press(KeyCode::Esc), &km); + // Modal swallows the cancel — the form must stay alive. + assert!(matches!(out, NewSecretAction::None)); + assert_eq!(view.confirm_exit(), Some(ConfirmButton::KeepEditing)); } #[test] @@ -819,10 +1157,10 @@ mod tests { #[test] fn tab_cycle_visits_every_field_and_wraps_to_path() { let km = KeyMap::default(); - // hm-r4i: cycling forward must hit every metadata field and wrap. + // hm-r4i + hm-3rr: cycling forward must hit every metadata field, + // pass through the new submit step, and wrap back to path. let mut view = NewSecretView::new(&empty_ctx()); typ(&mut view, "prod/KEY"); - // Value is multi-line, so skip past it explicitly. let expected = [ Step::Path, Step::Value, @@ -832,6 +1170,7 @@ mod tests { Step::Totp, Step::EnvKey, Step::ExpiresAt, + Step::Submit, Step::Path, // wrap-around ]; let mut seen = vec![view.step()]; @@ -843,14 +1182,92 @@ mod tests { } #[test] - fn shift_tab_wraps_backward_from_path_to_expires_at() { + fn shift_tab_wraps_backward_from_path_to_submit() { let km = KeyMap::default(); let mut view = NewSecretView::new(&empty_ctx()); assert_eq!(view.step(), Step::Path); view.on_key(back_tab(), &km); - assert_eq!(view.step(), Step::ExpiresAt); + // Submit is now the last tab stop. + assert_eq!(view.step(), Step::Submit); view.on_key(back_tab(), &km); - assert_eq!(view.step(), Step::EnvKey); + assert_eq!(view.step(), Step::ExpiresAt); + } + + #[test] + fn enter_on_submit_step_invokes_submit() { + let km = KeyMap::default(); + let mut view = NewSecretView::new(&empty_ctx()); + typ(&mut view, "prod/KEY"); + // Walk all the way to Submit (8 Tab presses from Path). + for _ in 0..8 { + view.on_key(press(KeyCode::Tab), &km); + } + assert_eq!(view.step(), Step::Submit); + let out = view.on_key(press(KeyCode::Enter), &km); + // Empty value rejects with focus snapped back to Value — confirms + // Enter on the Submit step reached submit(). The step transition + // (Submit → Value) gets translated into a hint sync; Value has + // no ambient tip, so ClearHint is the expected outcome. + assert!( + matches!(out, NewSecretAction::ClearHint), + "expected ClearHint, got {out:?}" + ); + assert_eq!(view.step(), Step::Value); + assert!(view.status().unwrap().contains("value")); + } + + #[test] + fn confirm_dialog_right_cycles_focused_button() { + let km = KeyMap::default(); + let mut view = NewSecretView::new(&empty_ctx()); + typ(&mut view, "x"); + view.on_key(press(KeyCode::Esc), &km); + assert_eq!(view.confirm_exit(), Some(ConfirmButton::KeepEditing)); + view.on_key(press(KeyCode::Right), &km); + assert_eq!(view.confirm_exit(), Some(ConfirmButton::Save)); + view.on_key(press(KeyCode::Right), &km); + assert_eq!(view.confirm_exit(), Some(ConfirmButton::Discard)); + } + + #[test] + fn confirm_dialog_left_reverses() { + let km = KeyMap::default(); + let mut view = NewSecretView::new(&empty_ctx()); + typ(&mut view, "x"); + view.on_key(press(KeyCode::Esc), &km); + assert_eq!(view.confirm_exit(), Some(ConfirmButton::KeepEditing)); + view.on_key(press(KeyCode::Left), &km); + assert_eq!(view.confirm_exit(), Some(ConfirmButton::Discard)); + view.on_key(press(KeyCode::Left), &km); + assert_eq!(view.confirm_exit(), Some(ConfirmButton::Save)); + } + + #[test] + fn confirm_dialog_enter_on_discard_emits_cancel() { + let km = KeyMap::default(); + let mut view = NewSecretView::new(&empty_ctx()); + typ(&mut view, "x"); + view.on_key(press(KeyCode::Esc), &km); + // KeepEditing → Save → Discard + view.on_key(press(KeyCode::Right), &km); + view.on_key(press(KeyCode::Right), &km); + assert_eq!(view.confirm_exit(), Some(ConfirmButton::Discard)); + let out = view.on_key(press(KeyCode::Enter), &km); + assert!(matches!(out, NewSecretAction::Cancel)); + } + + #[test] + fn confirm_dialog_esc_returns_to_form() { + let km = KeyMap::default(); + let mut view = NewSecretView::new(&empty_ctx()); + typ(&mut view, "x"); + view.on_key(press(KeyCode::Esc), &km); + assert!(view.confirm_exit().is_some()); + let out = view.on_key(press(KeyCode::Esc), &km); + assert!(matches!(out, NewSecretAction::None)); + assert!(view.confirm_exit().is_none()); + // The form is still editable: the buffer is intact. + assert_eq!(view.path(), "x"); } #[test] @@ -1010,7 +1427,10 @@ mod tests { // Belt-and-braces against pasted/injected input that bypasses the // typing-time filter. let err = parse_tags_input("a,bad tag,b").unwrap_err(); - assert!(err.contains("bad tag"), "error mentions offending tag: {err}"); + assert!( + err.contains("bad tag"), + "error mentions offending tag: {err}" + ); } #[test] @@ -1018,6 +1438,57 @@ mod tests { assert_eq!(parse_tags_input("").unwrap(), Vec::::new()); } + // ── hm-isi: ambient hints follow step focus ─────────────────────── + + #[test] + fn tab_off_path_emits_clear_hint_for_step_without_tip() { + // Path → Value: Path has a tip, Value has none. Leaving a tipped + // step for a tipless one must emit ClearHint so the router drops + // the stale tip. + let km = KeyMap::default(); + let mut view = NewSecretView::new(&empty_ctx()); + typ(&mut view, "prod/KEY"); + let action = view.on_key(press(KeyCode::Tab), &km); + assert!( + matches!(action, NewSecretAction::ClearHint), + "expected ClearHint on Path → Value, got {action:?}" + ); + assert_eq!(view.step(), Step::Value); + } + + #[test] + fn shift_tab_to_submit_emits_clear_hint() { + // Shift-Tab from initial Path wraps backward to Submit (no tip), + // so the router should clear any leftover hint. + let km = KeyMap::default(); + let mut view = NewSecretView::new(&empty_ctx()); + let action = view.on_key(back_tab(), &km); + match action { + NewSecretAction::ClearHint => {} + other => panic!("expected ClearHint, got {other:?}"), + } + assert_eq!(view.step(), Step::Submit); + } + + #[test] + fn typing_within_a_step_does_not_emit_hint_actions() { + // Plain character input must not churn the hint surface; only + // step transitions do. + let km = KeyMap::default(); + let mut view = NewSecretView::new(&empty_ctx()); + let action = view.on_key(press(KeyCode::Char('p')), &km); + assert!(matches!(action, NewSecretAction::None)); + } + + #[test] + fn hint_for_step_covers_path_tags_and_expires_at() { + assert!(NewSecretView::hint_for_step(Step::Path).is_some()); + assert!(NewSecretView::hint_for_step(Step::Tags).is_some()); + assert!(NewSecretView::hint_for_step(Step::ExpiresAt).is_some()); + assert!(NewSecretView::hint_for_step(Step::Value).is_none()); + assert!(NewSecretView::hint_for_step(Step::Submit).is_none()); + } + #[test] fn tags_step_filters_disallowed_characters_at_typing_time() { let km = KeyMap::default(); @@ -1046,6 +1517,10 @@ mod tests { // filter by writing straight to the buffer simulates a paste. view.tags = "a".repeat(65); let out = view.on_key(ctrl('s'), &km); + // Submit refocuses Tags (validation error); the step transition + // currently doesn't trigger a hint swap because Ctrl+S routes + // through `dispatch_action` (router-level intent) rather than + // navigation. The action here is the original `None` from submit(). assert!(matches!(out, NewSecretAction::None)); assert_eq!(view.step(), Step::Tags); assert!(view.status().is_some()); diff --git a/rust/src/tui/views/remote_add.rs b/rust/src/tui/views/remote_add.rs index 5cbaafa..e36970d 100644 --- a/rust/src/tui/views/remote_add.rs +++ b/rust/src/tui/views/remote_add.rs @@ -113,12 +113,14 @@ impl ProtoForm for RemoteAddArgs { "org/repo (e.g. acme/secrets) or a full git URL", ) .required() - .with_validator(validate_slug_or_url), + .with_validator(validate_slug_or_url) + .placeholder("myorg/myrepo"), Field::text( "url", "URL", "optional override; defaults to git@github.com:.git", - ), + ) + .placeholder("git@github.com:myorg/myrepo.git"), ] } diff --git a/rust/src/tui/views/search.rs b/rust/src/tui/views/search.rs index b0d0161..290eb3b 100644 --- a/rust/src/tui/views/search.rs +++ b/rust/src/tui/views/search.rs @@ -30,6 +30,7 @@ use crate::tui::icons; use crate::tui::keymap::{KeyAction, KeyMap}; use crate::tui::views::command_palette::{Command, CommandPalette, CommandPaletteOutcome}; use crate::tui::views::store_picker::{StorePicker, StorePickerOutcome}; +use crate::tui::widgets::secret_ref_autocomplete::SecretRefAutocomplete; /// Outcome of handling a key — lets the app router decide where to go next. #[derive(Debug, Clone)] @@ -164,6 +165,17 @@ pub struct SearchView { /// `FoldedGroup` row. Toggled with Tab. Singleton paths render the same /// in both states. Default: unfolded. folded: bool, + /// Levenshtein-backed autocomplete popup over the search query. + /// + /// Wired here (rather than into the secret-viewer rename path, where it + /// would also be useful) because the search bar is the highest-frequency + /// "I'm typing a reference to a secret" surface in the TUI: every user who + /// opens the TUI lands on this view first. The popup is non-modal — the + /// query input keeps consuming every printable key — and only intercepts + /// Up/Down/Enter while the user has explicitly opened it via Ctrl+Space. + /// Tab is already bound to fold-toggle so we picked Ctrl+Space; that + /// chord is broadly available on macOS/iTerm + Linux terminals. + autocomplete: SecretRefAutocomplete, } impl SearchView { @@ -188,6 +200,7 @@ impl SearchView { show_store_column: false, env_index, folded: false, + autocomplete: SecretRefAutocomplete::new(Vec::new()), }; view.refresh_results(); view @@ -235,12 +248,35 @@ impl SearchView { } } + // Ctrl+Space opens the autocomplete popup. We re-toggle it (rather + // than only opening) so a user who pulled it up by accident can + // dismiss it with the same chord. + if matches!(key.code, KeyCode::Char(' ')) && key.modifiers.contains(KeyModifiers::CONTROL) { + let want_open = !self.autocomplete.is_open(); + self.autocomplete.set_open(want_open); + return SearchAction::None; + } + // Esc closes the popup before falling through to the view's own + // cancel/quit semantics. + if key.code == KeyCode::Esc && self.autocomplete.is_open() { + self.autocomplete.set_open(false); + return SearchAction::None; + } + match (key.code, key.modifiers) { (KeyCode::Tab, _) => { self.toggle_fold(); SearchAction::None } (KeyCode::Enter, _) => { + // Open popup wins: Enter accepts the highlighted suggestion + // into the query field. + if let Some(pick) = self.autocomplete.accepted() { + self.query = pick.to_string(); + self.autocomplete.set_open(false); + self.refresh_results(); + return SearchAction::None; + } // On a folded group, Enter expands the entire view (1-level // unfold) and lands the cursor on the first leaf of the // group the user just opened. @@ -254,11 +290,19 @@ impl SearchView { } } (KeyCode::Up, _) => { - self.select_prev(); + if self.autocomplete.is_open() { + self.autocomplete.move_selection(-1); + } else { + self.select_prev(); + } SearchAction::None } (KeyCode::Down, _) => { - self.select_next(); + if self.autocomplete.is_open() { + self.autocomplete.move_selection(1); + } else { + self.select_next(); + } SearchAction::None } (KeyCode::Backspace, _) => { @@ -349,17 +393,30 @@ impl SearchView { self.results = search_core(&self.ctx, &self.query, &[]).unwrap_or_default(); self.rows = build_rows(&self.results, self.folded); self.list_state.select(self.first_selectable()); + // Keep the autocomplete corpus aligned with what the user could + // possibly land on: every secret path search_core just returned for + // an unfiltered scan. This is cheap (already in memory) and dodges + // having to re-walk the store when the popup wants to open. + let corpus: Vec = self.results.iter().map(|r| r.path.clone()).collect(); + self.autocomplete.set_corpus(corpus); + self.autocomplete.update_query(&self.query); } fn toggle_fold(&mut self) { // Remember the prefix or path under the cursor so we can re-anchor // the selection after rebuilding rows. Otherwise the cursor would // jump to the first selectable line on every toggle. - let anchor = self.list_state.selected().and_then(|i| self.rows.get(i)).map(|row| match row { - Row::Secret { result, .. } => SelectionAnchor::Path(result.path.clone(), result.store.clone()), - Row::FoldedGroup { prefix, .. } => SelectionAnchor::Prefix(prefix.clone()), - Row::Store { name, .. } => SelectionAnchor::Store(name.clone()), - }); + let anchor = self + .list_state + .selected() + .and_then(|i| self.rows.get(i)) + .map(|row| match row { + Row::Secret { result, .. } => { + SelectionAnchor::Path(result.path.clone(), result.store.clone()) + } + Row::FoldedGroup { prefix, .. } => SelectionAnchor::Prefix(prefix.clone()), + Row::Store { name, .. } => SelectionAnchor::Store(name.clone()), + }); self.folded = !self.folded; self.rows = build_rows(&self.results, self.folded); @@ -375,13 +432,15 @@ impl SearchView { self.folded = false; self.rows = build_rows(&self.results, self.folded); let target = self.rows.iter().position(|row| match row { - Row::Secret { result, shared_prefix, .. } => { - shared_prefix.as_deref() == Some(prefix) - || prefix_of(&result.path) == prefix - } + Row::Secret { + result, + shared_prefix, + .. + } => shared_prefix.as_deref() == Some(prefix) || prefix_of(&result.path) == prefix, _ => false, }); - self.list_state.select(target.or_else(|| self.first_selectable())); + self.list_state + .select(target.or_else(|| self.first_selectable())); } fn selected_result(&self) -> Option<&SearchResult> { @@ -421,8 +480,17 @@ impl SearchView { (SelectionAnchor::Path(p, _), Row::FoldedGroup { prefix, .. }) => { prefix_of(p) == prefix.as_str() } - (SelectionAnchor::Prefix(prefix), Row::FoldedGroup { prefix: p, .. }) => prefix == p, - (SelectionAnchor::Prefix(prefix), Row::Secret { shared_prefix, result, .. }) => { + (SelectionAnchor::Prefix(prefix), Row::FoldedGroup { prefix: p, .. }) => { + prefix == p + } + ( + SelectionAnchor::Prefix(prefix), + Row::Secret { + shared_prefix, + result, + .. + }, + ) => { shared_prefix.as_deref() == Some(prefix.as_str()) || prefix_of(&result.path) == prefix } @@ -495,6 +563,12 @@ impl SearchView { self.draw_results(frame, chunks[3]); self.draw_footer(frame, chunks[5]); + // The autocomplete popup sits between the input bar and the modal + // overlays — picker/palette still need to draw on top of it when + // they are open, but the popup itself should hide whatever it + // overlaps in the results area. + self.autocomplete.draw(frame, chunks[2]); + // Render the picker / palette overlays last so they sit on top of // the rest of the chrome. if let Some(picker) = self.picker.as_mut() { @@ -787,7 +861,10 @@ impl SearchView { .max("ENVS".len()); let desc_w = row_data .iter() - .filter_map(|d| d.as_ref().map(|c| desc_cell_width(&c.desc, c.tags.as_deref()))) + .filter_map(|d| { + d.as_ref() + .map(|c| desc_cell_width(&c.desc, c.tags.as_deref())) + }) .max() .unwrap_or(0) .max("DESCRIPTION".len()); @@ -857,7 +934,11 @@ impl SearchView { ]); ListItem::new(line) } - Row::FoldedGroup { prefix, count, indent } => { + Row::FoldedGroup { + prefix, + count, + indent, + } => { let pad_indent = " ".repeat(*indent); let line = Line::from(vec![ Span::raw(pad_indent), @@ -872,7 +953,11 @@ impl SearchView { ]); ListItem::new(line) } - Row::Secret { result, shared_prefix, .. } => { + Row::Secret { + result, + shared_prefix, + .. + } => { let cells = data.as_ref().unwrap(); // Compose the path cell. The parent prefix is split into // a "shared" segment (top-level path slice when this leaf @@ -897,10 +982,7 @@ impl SearchView { .fg(theme::accent()) .add_modifier(Modifier::DIM), ), - Span::styled( - rest_seg.to_string(), - Style::default().fg(theme::path_dim()), - ), + Span::styled(rest_seg.to_string(), Style::default().fg(theme::path_dim())), Span::raw(cells.basename.clone()), Span::raw(format!("{:) -> usize { /// the per-row right-padding so they can't drift apart. fn desc_cell_width(desc: &str, tags: Option<&[String]>) -> usize { let chips_w = tag_chips_width(tags); - let sep = if !desc.is_empty() && chips_w > 0 { 1 } else { 0 }; + let sep = if !desc.is_empty() && chips_w > 0 { + 1 + } else { + 0 + }; desc.len() + sep + chips_w } @@ -1056,9 +1142,7 @@ fn build_env_index() -> std::collections::HashMap> { // need the full path list, Tag needs decryption). The // label still shows up against any explicit Single/Alias // references. - EnvEntry::Glob(_) - | EnvEntry::Tag(_) - | EnvEntry::AliasTag { .. } => {} + EnvEntry::Glob(_) | EnvEntry::Tag(_) | EnvEntry::AliasTag { .. } => {} } } } diff --git a/rust/src/tui/views/secret_viewer.rs b/rust/src/tui/views/secret_viewer.rs index e5ef76d..ca90ac2 100644 --- a/rust/src/tui/views/secret_viewer.rs +++ b/rust/src/tui/views/secret_viewer.rs @@ -1457,7 +1457,11 @@ s3cret"; let parsed = parse_edit_doc(doc).unwrap(); assert_eq!( parsed.tags, - vec!["pci".to_string(), "stripe".to_string(), "mobile".to_string()] + vec![ + "pci".to_string(), + "stripe".to_string(), + "mobile".to_string() + ] ); } diff --git a/rust/src/tui/widgets/mod.rs b/rust/src/tui/widgets/mod.rs new file mode 100644 index 0000000..a317eb9 --- /dev/null +++ b/rust/src/tui/widgets/mod.rs @@ -0,0 +1,3 @@ +//! Reusable ratatui widgets shared across views. + +pub mod secret_ref_autocomplete; diff --git a/rust/src/tui/widgets/secret_ref_autocomplete.rs b/rust/src/tui/widgets/secret_ref_autocomplete.rs new file mode 100644 index 0000000..c2bb0d6 --- /dev/null +++ b/rust/src/tui/widgets/secret_ref_autocomplete.rs @@ -0,0 +1,255 @@ +//! Floating autocomplete popup for "typing a reference to a secret" surfaces. +//! +//! Backed by [`crate::suggest`] so the same Levenshtein code that produces the +//! CLI "did you mean" hint also drives this popup. The widget is intentionally +//! dumb: it owns the corpus and the current query, but it is non-modal — the +//! host view keeps every key event and merely calls [`update_query`] / +//! [`move_selection`] / [`accepted`] as appropriate. + +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState}; +use ratatui::Frame; + +use crate::suggest; +use crate::tui::theme; + +/// Maximum number of entries the popup ever shows. Five is enough to surface +/// realistic alternatives without crowding the underlying view. +const MAX_SUGGESTIONS: usize = 5; + +/// Reusable autocomplete popup for secret-path inputs. +pub struct SecretRefAutocomplete { + corpus: Vec, + query: String, + suggestions: Vec, + selected: usize, + open: bool, +} + +impl SecretRefAutocomplete { + pub fn new(corpus: Vec) -> Self { + Self { + corpus, + query: String::new(), + suggestions: Vec::new(), + selected: 0, + open: false, + } + } + + /// Replace the corpus that suggestions are computed against. Re-runs the + /// matcher so the visible list stays in sync. + pub fn set_corpus(&mut self, corpus: Vec) { + self.corpus = corpus; + self.recompute(); + } + + /// Update the query string and recompute suggestions. The popup auto-opens + /// when there is at least one suggestion to show; consumers can still + /// force it closed via [`set_open`]. + pub fn update_query(&mut self, q: &str) { + if self.query == q { + return; + } + self.query = q.to_string(); + self.recompute(); + } + + /// Toggle the popup visibility. Useful when the host wires Tab/Ctrl-Space + /// to dismiss the popup without touching the query. + pub fn set_open(&mut self, open: bool) { + self.open = open && !self.suggestions.is_empty(); + } + + pub fn is_open(&self) -> bool { + self.open + } + + /// Move the selection cursor by `delta`, wrapping around at both ends. + /// No-op when the popup is closed or the suggestion list is empty. + pub fn move_selection(&mut self, delta: isize) { + if !self.open || self.suggestions.is_empty() { + return; + } + let len = self.suggestions.len() as isize; + let next = (self.selected as isize + delta).rem_euclid(len); + self.selected = next as usize; + } + + /// The currently highlighted suggestion, if any. Returns `None` when the + /// popup is closed or no candidates survived the filter. + pub fn accepted(&self) -> Option<&str> { + if !self.open { + return None; + } + self.suggestions.get(self.selected).map(String::as_str) + } + + /// Render the popup anchored to `anchor`. Drawn below the anchor when + /// space allows, otherwise above. When the popup is closed or empty this + /// is a no-op. + pub fn draw(&self, frame: &mut Frame<'_>, anchor: Rect) { + if !self.open || self.suggestions.is_empty() { + return; + } + + let frame_area = frame.area(); + let height = (self.suggestions.len() as u16 + 2).min(MAX_SUGGESTIONS as u16 + 2); + let width = anchor.width.max(20); + + let space_below = frame_area.height.saturating_sub(anchor.y + anchor.height); + let (y, popup_h) = if space_below >= height { + (anchor.y + anchor.height, height) + } else if anchor.y >= height { + (anchor.y.saturating_sub(height), height) + } else { + // Cramped: clamp to whatever fits below. + (anchor.y + anchor.height, space_below.max(2)) + }; + let popup = Rect { + x: anchor.x, + y, + width, + height: popup_h, + }; + + // Title differentiates "exact prefix exists" from "did you mean": + // when none of the suggestions start with the query we treat it as a + // typo-recovery hint, otherwise as a live completion. + let title = if self.suggestions.iter().any(|s| s.starts_with(&self.query)) { + " matches " + } else { + " did you mean " + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::new().fg(theme::border())) + .title(title) + .title_style(Style::default().fg(theme::border_label())); + + let items: Vec = self + .suggestions + .iter() + .map(|s| ListItem::new(Line::from(Span::raw(s.clone())))) + .collect(); + + let list = List::new(items).block(block).highlight_style( + Style::default() + .bg(theme::accent()) + .fg(theme::on_accent()) + .add_modifier(Modifier::BOLD), + ); + + let mut state = ListState::default(); + state.select(Some(self.selected)); + + frame.render_widget(Clear, popup); + frame.render_stateful_widget(list, popup, &mut state); + } + + fn recompute(&mut self) { + let max_dist = suggest::default_max_distance(&self.query); + self.suggestions = + suggest::suggest_top_n(&self.query, &self.corpus, MAX_SUGGESTIONS, max_dist); + if self.selected >= self.suggestions.len() { + self.selected = 0; + } + // Empty-query / empty-corpus drive the popup closed; otherwise the + // host decides whether to show it (gated behind Tab/Ctrl-Space). + if self.suggestions.is_empty() || self.query.is_empty() { + self.open = false; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn corpus() -> Vec { + vec![ + "prod/api/STRIPE_KEY".to_string(), + "prod/api/GITHUB_TOKEN".to_string(), + "prod/db/POSTGRES_URL".to_string(), + "staging/api/STRIPE_KEY".to_string(), + ] + } + + #[test] + fn empty_corpus_yields_no_suggestions() { + let mut ac = SecretRefAutocomplete::new(Vec::new()); + ac.update_query("anything"); + ac.set_open(true); + assert!(ac.accepted().is_none()); + assert!(!ac.is_open()); + } + + #[test] + fn exact_match_appears_first() { + let mut ac = SecretRefAutocomplete::new(corpus()); + ac.update_query("prod/api/STRIPE_KEY"); + ac.set_open(true); + assert_eq!(ac.accepted(), Some("prod/api/STRIPE_KEY")); + } + + #[test] + fn typo_within_threshold_is_suggested() { + let mut ac = SecretRefAutocomplete::new(corpus()); + ac.update_query("prod/api/STRIPE_KYE"); + ac.set_open(true); + assert_eq!(ac.accepted(), Some("prod/api/STRIPE_KEY")); + } + + #[test] + fn typo_outside_threshold_yields_nothing() { + let mut ac = SecretRefAutocomplete::new(corpus()); + ac.update_query("totally_unrelated_thing_with_no_overlap"); + ac.set_open(true); + assert!(ac.accepted().is_none()); + assert!(!ac.is_open()); + } + + #[test] + fn move_selection_wraps_around() { + // A small synthetic corpus with several entries within distance ≤ 2 + // of the query so we can exercise wrap-around deterministically. + let local = vec![ + "alpha".to_string(), + "alphas".to_string(), + "alpine".to_string(), + ]; + let mut ac = SecretRefAutocomplete::new(local); + ac.update_query("alpha"); + ac.set_open(true); + let first = ac.accepted().unwrap().to_string(); + ac.move_selection(-1); + let last = ac.accepted().unwrap().to_string(); + assert_ne!(first, last, "wrap should land on a different entry"); + // Wrap forward should return to the first entry. + ac.move_selection(1); + assert_eq!(ac.accepted().map(String::from), Some(first)); + } + + #[test] + fn closed_popup_returns_no_acceptance() { + let mut ac = SecretRefAutocomplete::new(corpus()); + ac.update_query("prod"); + // Default state (open=false) means accepted() is silent. + assert!(ac.accepted().is_none()); + } + + #[test] + fn set_corpus_refreshes_suggestions() { + let mut ac = SecretRefAutocomplete::new(Vec::new()); + ac.update_query("alpha"); + ac.set_open(true); + assert!(!ac.is_open()); + + ac.set_corpus(vec!["alpha".to_string(), "alphas".to_string()]); + ac.set_open(true); + assert_eq!(ac.accepted(), Some("alpha")); + } +} diff --git a/tests/integration/cli_test.rs b/tests/integration/cli_test.rs index d70b76a..9023ba0 100644 --- a/tests/integration/cli_test.rs +++ b/tests/integration/cli_test.rs @@ -600,6 +600,25 @@ fn search_no_matches_returns_empty() { .stdout(predicate::str::is_empty()); } +#[test] +fn search_typo_suggests_closest_match() { + let (home, store) = setup(); + let s = store_flag(&store); + + himitsu() + .env("HIMITSU_CONFIG", home.path().join("config.yaml")) + .args(["--store", &s, "set", "prod/api/STRIPE_KEY", "sk_test"]) + .assert() + .success(); + + himitsu() + .env("HIMITSU_CONFIG", home.path().join("config.yaml")) + .args(["--store", &s, "search", "prod/api/STRIPE_KYE"]) + .assert() + .success() + .stderr(predicate::str::contains("did you mean prod/api/STRIPE_KEY")); +} + // ============ version and help tests ============ #[test]