Skip to content

feat(shortcut): single/double press detection for Ctrl+G - #31

Merged
0xMMA merged 37 commits into
mainfrom
feature/shortcut-double-press
Oct 9, 2026
Merged

0xMMA merged 37 commits into
mainfrom
feature/shortcut-double-press

Conversation

@0xMMA

@0xMMA 0xMMA commented Apr 6, 2026 •

Copy link
Copy Markdown
Owner

Closes #30

Summary

Started as a 200 ms double-press detector on top of RegisterHotKey; that cannot see a second tap while Ctrl is held, so the branch replaced the input capture with a low-level keyboard hook and made shortcuts configurable.

  • Windows input capture: WH_KEYBOARD_LL hook (internal/features/shortcut/service_windows.go) tracks modifier state, suppresses the matched trigger key so the foreground app never sees it, and runs the double-tap state machine on the hook thread.
  • Two modes: double_tap (single tap → fix; second tap within the delay while modifiers are held → pyramidize; releasing the modifier ends the wait early) and independent (two separate bindings, each fires on keyup).
  • Configurable shortcuts: settings fields shortcut_mode, shortcut_fix, shortcut_pyramidize, shortcut_double_tap_delay (100–500 ms, default 200); legacy shortcut_key is ignored (Ctrl+G was hard-wired up to v4.5.0-beta, so it never held a live value); unusable values in the file are replaced on load and refused on Save. Key-combo parser with VK mapping (keycombo.go + tests). Saved shortcuts apply immediately (settings OnSaved hook in main.go).
  • Settings UI: mode toggle plus a shortcut recorder component (frontend/src/app/features/settings/shortcut-recorder/); recording pauses the hook.
  • Events: shortcut:fix / shortcut:pyramidize replace shortcut:triggered; frontend exposes shortcutFix$ / shortcutPyramidize$; the silent-fix subscription moved to ShellComponent so it works on every route; Pyramidize no longer captures the clipboard on a single press.
  • Clipboard: release Shift/Alt before injecting Ctrl+C/V, poll for the clipboard change instead of a fixed sleep, skip capture on the second tap.

Design: docs/superpowers/specs/2026-04-06-keyboard-hook-configurable-shortcuts-design.md (supersedes the double-press spec in the same folder).

Test plan

  • Go: key-combo parser tests, go test ./internal/... (CI)
  • Frontend: Vitest incl. updated subscription tests (CI)
  • Windows manual, on the dev-channel build of this PR: Settings → About → Dev channel → install PR feat(shortcut): single/double press detection for Ctrl+G #31 build (prerelease v0.0.0-pr.31, always the latest CI run of this branch; Developer options are unlocked by tapping the version). Fallback: the KeyLint-windows-amd64-setup artifact of the latest CI run.
    • version string v4.4.3-beta-35-ga04faa3 — obsolete: the merge of main (2026-10-08) superseded that build; the dev-channel build carries the 2026-09-17 fixes and main's newer work.
    • single Ctrl+G in any app → silent fix, no stray "g" typed into the app, regardless of KeyLint's current route or window visibility
    • hold Ctrl, tap G twice → KeyLint comes to front on Pyramidize with the clipboard text loaded — also when KeyLint was on Fix or Settings before
    • independent mode: Ctrl+G → fix, Ctrl+Shift+G → Pyramidize
    • recorder: record a new combo, save → active immediately without restart; restart the app, combo still active; recording itself triggers no action
    • recorder: start recording, then leave Settings without pressing a key → Ctrl+G still works
    • save an invalid combo (e.g. a bare letter) → Settings shows an error, old combo keeps working
    • an old settings.json (v4.5.0-beta, only shortcut_key) loads without error and Ctrl+G works
    • 200 ms delay feels right; single tap has no perceptible lag

Update 2026-09-17

Root cause of the double-fire. SetTimer with a NULL hWnd ignores the nIDEvent argument and returns a newly generated timer ID; the code passed a constant 1 and killed the timer with that same 1, so KillTimer never matched. Because SetTimer timers are periodic, every first tap leaked a timer that kept posting WM_TIMER forever. A stray timer then fired a silent fix milliseconds into the next first tap, the second tap was treated as a fresh first tap, and the second paste landed after the first paste's collapsed caret — so hold-Ctrl double-tap ran fix twice and Pyramidize was unreachable.

What changed.

  • fix(shortcut): kill the double-tap timer by its real Win32 ID (#30) — the service now stores the ID SetTimer returns, routes every kill site through a stopTimer() helper (keyup, modifier release, second tap, UpdateConfig, SetPaused, Unregister), ignores WM_TIMER whose wParam is not the current ID, and kills the timer when it expires. If SetTimer ever fails it logs a warning and degrades to a plain single-tap fix instead of swallowing the key. The bogus doubleTapTimerID constant is gone.
  • fix(shell): ignore shortcut while a silent fix is in flight (#30) — ShellComponent.silentFix() now guards against re-entry, so two fix events can no longer run two concurrent read → enhance → write → paste cycles and paste on top of each other. Covered by a new Vitest case (138 tests green).
  • fix(shortcut): reset double-tap state on the pump thread (#30) — a SetTimer(NULL, …) timer belongs to the thread that created it, so the KillTimer calls made from Unregister, UpdateConfig and SetPaused on their own goroutines could never kill it; those callers now post a wmReset message and the message loop does the reset on the pump thread, which also removes the cross-goroutine writes to tapState/indepPending.
  • fix(shell): release silent-fix guard after a safety timeout (#30) — a 120 s safety timer releases silentFixInFlight if the enhance call never settles (the backend HTTP timeout lands in feat(provider): Claude Code CLI as first-class provider (claude -p) #32), plus reject-path and timeout-path tests (140 green); on the Windows side a failed SetTimer now stays in tapWaitRelease so auto-repeat cannot fire one fix per repeat.
  • fix(shortcut): absorb held trigger key after the double-tap window expires (#30) — holding the shortcut down used to fire one fix per delay interval, because the expiring timer dropped the state machine to tapIdle and every auto-repeat keydown read as a fresh first tap; a new tapFiredWaitRelease phase fires the fix once and swallows the trigger key until its keyup.

Verification. GOOS=windows go vet ./... clean apart from three pre-existing possible misuse of unsafe.Pointer warnings, Windows cross-compile with mingw succeeds, go test ./internal/... and cd frontend && npm test green on Linux.

Manual checklist reset. All Windows manual items above are unchecked again and must be re-run against the artifact from the CI run for a04faa3 — no earlier artifact carries this fix. (2026-10-08: use the dev-channel build instead, see the test plan.)

Update 2026-10-08

Merged origin/main (settings atomic writes + settings.Update, provider switching #104, dev channel #107, shell footer #106, Go 1.27, CI apt fixes). Conflicts in shell, wails.service, settings imports and bindings; both sides kept. Then fixed what the merge review found:

  • fix(shortcut): apply saved shortcuts at once, refuse unusable ones — nothing ever emitted settings:changed, so the hot-reload never ran. Settings now calls an OnSaved hook after every write; main.go re-applies shortcuts when they changed (and installs the hook if startup registration failed). Save refuses an unusable shortcut change; load() repairs unusable values; the shortcut_key migration is gone (it never fired, and firing it would have applied unvalidated free text).
  • fix(enhance): load clipboard for a shortcut sent from another page — Pyramidize by shortcut from any page but /enhance opened empty: the event fired before the page subscribed. WailsService now keeps it pending until a page takes it.
  • fix(settings): resume shortcuts when the recorder goes away — leaving Settings mid-recording left the hook paused until restart; Shift+digit records the digit.

🤖 Generated with Claude Code

0xMMA and others added 26 commits April 6, 2026 00:57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…#30)

CopyFromForeground() sleeps ~150ms (Ctrl+C + wait). Capturing on every
press ate into the 200ms detection window, leaving only ~50ms margin.
Use atomic.Bool to capture only on the first press of each cycle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
#30)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ble shortcuts (#30)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…gurable shortcuts (#30)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…hook (#30)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ents (#30)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ok (#30)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…cutPyramidize$, add settings fields (#30)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…corder (#30)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three bugs fixed:
- Auto-repeat: track trigger keyup in double-tap state machine. Only
  accept second tap after the trigger key has been physically released
  and pressed again (tapWaitRelease → tapWaitSecond phases).
- Recording conflict: add SetPaused() to service interface. Recorder
  component pauses the hook during capture mode so keypresses pass
  through to the webview instead of being intercepted.
- Diagnostic logging: add Debug-level trace logs in hook callback for
  modifier tracking, tap phases, and combo matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
FixComponent is only mounted on the /fix route. When the app is hidden
or on another route, the shortcutFix$ subscription didn't exist, so
the silent fix never ran. Move it to ShellComponent which is always
mounted — read clipboard, enhance, write back, paste to foreground.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
#30)

Outlook uses delayed clipboard rendering — the fixed 150ms sleep wasn't
enough for the clipboard to be populated. Now polls
GetClipboardSequenceNumber in 25ms intervals up to 1s timeout.
Fast apps still finish in ~25-50ms; slow apps like Outlook get the
full second.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the shortcut combo includes Shift or Alt (e.g. Ctrl+Shift+G for
pyramidize), those modifiers are still physically held when
CopyFromForeground sends Ctrl+C. The target app sees Ctrl+Shift+C
instead of Ctrl+C, which Outlook and other apps silently ignore.

Fix: inject Shift-up and Alt-up events before Ctrl+C (and Ctrl+V)
so the receiving app always gets a clean Ctrl+C/Ctrl+V regardless of
which modifiers the shortcut combo used.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…wn (#30)

Gives the user time to release modifier keys before CopyFromForeground
runs, reducing the chance of Ctrl+Shift+C instead of Ctrl+C.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
0xMMA and others added 2 commits September 17, 2026 22:06
With a NULL hWnd, SetTimer ignores the nIDEvent argument and returns a newly
generated ID, so KillTimer(NULL, 1) never killed anything and every first tap
leaked a periodic timer that kept posting WM_TIMER forever. The stray timer
fired a silent fix milliseconds into the next first tap, so a hold-Ctrl
double-tap ran fix twice and Pyramidize was unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two fix events used to start two concurrent read -> enhance -> write -> paste
cycles, and the second paste landed on top of the first result. A guard flag
now drops the shortcut while a cycle is running and logs a warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A SetTimer(NULL, ...) timer belongs to the thread that created it, so the
KillTimer calls that Unregister, UpdateConfig and SetPaused made from their
own goroutines could never kill it — and clearing timerID there dropped the
only handle, leaking the periodic timer. Those callers now post wmReset and
the message loop does the reset on the pump thread, which also removes the
cross-goroutine writes to tapState and indepPending. The config fields stay
as they are; they are already guarded by mu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0xMMA and others added 2 commits September 17, 2026 22:19
The backend has no HTTP timeout yet (#32), so a hung enhance call would leave
silentFixInFlight set and swallow every later shortcut. A 120 s safety timer
now releases the guard and logs a warning; it is cleared in the finally block.
The timeout is an overridable field so the tests need not wait for it.

Also on the Windows side: when SetTimer fails the service now stays in
tapWaitRelease with timerID == 0 instead of going idle, so auto-repeat is
absorbed rather than firing a fix per repeat, the trigger keyup then starts
over instead of arming pyramidize, and the modifier-release path only fires
fix while a timer is actually pending so the failure path cannot fire twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pires (#30)

When the timer expired while the trigger key was still down, the state machine
dropped to tapIdle and every auto-repeat keydown looked like a fresh first tap,
so holding the shortcut fired one fix per delay interval. A new
tapFiredWaitRelease phase fires the fix once and swallows the trigger key until
its keyup, which returns to tapIdle; a modifier release in that phase no longer
fires a second fix. The phase is reached from the hook's own keydown/keyup
events — tapWaitRelease already means "trigger physically down" — and it also
replaces the timerID == 0 marker used on the SetTimer-failure path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0xMMA added a commit that referenced this pull request Sep 17, 2026
…housekeeping items

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
0xMMA added a commit that referenced this pull request Sep 18, 2026
…5 blocked by @angular/build peer) (#84)

* chore(frontend): upgrade Vitest, Playwright, jsdom and Prettier

Refs #35, E4 step 3. devDependencies only — Angular, PrimeNG and TypeScript
stay where they are and wait for #31.

  @playwright/test  1.58.2 -> 1.63.0
  jsdom             28.1.0 -> 30.1.0
  prettier          3.8.1  -> 3.9.8
  vitest            4.0.8  -> 5.0.1

One commit rather than one per tool: no changelog needed adapting. No config
key changed, no spec changed, and the three things that looked like they might
need work turned out not to.

VITEST 5 IS OUTSIDE ANGULAR'S DECLARED PEER RANGE

`@angular/build:unit-test` is what `ng test` drives, and it declares vitest as
an optional peer at `^4.0.8` — on every 21.2.x and on 22.1.8, the newest
published. So this pairing is unsupported upstream, not merely untested, and
`npm install` prints `ERESOLVE overriding peer dependency`.

Shipped anyway, on evidence rather than optimism: `npm ci` from a clean
node_modules resolves it and exits 0, all 14 files and 198 tests pass, the test
count is identical to vitest 4, and a deliberately broken assertion still fails
one test — so the suite is running and biting, not passing for the wrong
reason. The caveat is written into .claude/rules/testing.md next to the runner,
because the peer warning in an install log is otherwise the kind of thing
someone "fixes" by downgrading the wrong package.

PRETTIER 3.9 REFORMATS NOTHING HERE

The brief asked for the formatting change to be kept out of the diff if 3.9
moved anything. It does not: 3.8 and 3.9 report the same 30 files under
`--list-different`, and the sets are identical. The repo is simply not
Prettier-formatted and Prettier is not enforced anywhere, so there is no
formatting commit and no .prettierrc pin.

JSDOM 30 CHANGED NOTHING WE DEPEND ON

`matchMedia`, `ResizeObserver` and `IntersectionObserver` are still undefined —
checked twice, once against the library and once with a throwaway spec inside
the running test environment, because those are not the same thing. The
polyfill rule in .claude/rules/testing.md stands and now says it was verified
against 30, so the next bump does not re-derive it.

PLAYWRIGHT 1.63

Every config key still applies: 58 passed and 3 skipped, exercising webServer,
baseURL, screenshot, trace and the chromium project. One full run of four had
`router-leave-settings` fail; it passes 3 of 3 in isolation and 58/58 on the
next two full runs. That is the flake class the config's own comment describes
and `retries: 1` in CI exists for. Not attributed to 1.63 — establishing that
would need the same repetition on 1.58, which was not done.

Co-authored-by: Michael <0xMMA@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* chore(frontend): keep vitest on 4.x; correct what the rules said about isolation

Review round on #84. Two of my own claims in .claude/rules/testing.md were
wrong, and the vitest 5 bump costs more than it buys.

VITEST STAYS ON 4.1.11, NOT 5.0.1

The evidence I shipped it on was about the test RUN — clean `npm ci`, identical
test count, a broken assertion still failing — and all of that holds. It missed
the thing developers actually do: `npm install` fails outright.

  npm error code ERESOLVE
  npm error Found: vitest@5.0.1 ... peerOptional vitest@"^4.0.8" from @angular/build

Not a warning, an error with a non-zero exit, for every package anyone tries to
add, until they know to pass --legacy-peer-deps. CI runs `npm ci` everywhere, so
CI would never have caught it — my green pipeline was not evidence about this.
A test-runner major is not worth breaking local installs for, so the bump stops
at 4.1.11: inside the declared peer range, `npm install --dry-run` clean, 198
tests green, and `clearMocks` still defaults to false as it did on 4.0.8.

The conditions for moving to 5 are written down instead of lost: the builder has
to declare `^5`, and the same change has to handle vitest 5 flipping
`clearMocks` to true by default — the builder does not set it, so a mock's calls
stop surviving into the next test and an assertion on call counts that should
fail could start passing.

SPEC FILES ARE NOT ISOLATED, AND I WROTE THE OPPOSITE

I claimed each spec file gets its own environment. The builder sets
`isolate: false`, so files in one fork share a process, a globalThis and one
jsdom window. Measured: 11 of 14 files landed in a single fork and saw
`ResizeObserver` and `matchMedia` already defined on entry, set by two files
that ran before them.

That also explains the leak I reported as "observed once, did not reproduce in
ten runs" — it reproduces perfectly, it just depends on which fork a file lands
in. The advice was right and the reason was wrong, which is the more damaging
half: it told the next author that cross-file state cannot happen.

The jsdom section is corrected the same way. `matchMedia`, `ResizeObserver` and
`IntersectionObserver` are undefined in jsdom 30 and in a spec run on its own —
but in a full run a spec may see them anyway, so a green suite is not proof that
your stub is there. The rule now says to run a new or changed spec on its own
once, with the command to do it.

Co-authored-by: Michael <0xMMA@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Michael <0xMMA@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
0xMMA and others added 2 commits September 20, 2026 00:03
Resolves the settings model conflict (shortcut fields + Models map) and
regenerates the frontend bindings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The spec read `[data-testid="shortcut-input"]`, a plain text input this branch
replaced with the mode toggle and the recorder component. `inputValue()` on a
locator that matches nothing does not fail — it waits, so the test spent the
full 60s timeout before CI called it red.

What it is really for is that settings reached the UI at all, so it now checks
that through the elements that exist: the mode toggle renders, the Fix
recorder shows `ctrl+g` as the recorder formats it ("Ctrl + G"), and the
Pyramidize section is absent because double-tap mode has one shortcut.

The delay slider is asserted too, and not as decoration: it exists only in
double-tap mode, so without it the assertion that Pyramidize is hidden would
pass just as happily in a mode where nothing renders at all. Browser mode
defaults to double_tap (`wails.service.ts`, BROWSER_MODE_DEFAULTS) — asserted
rather than assumed.

Retrying assertions throughout, no `.count()` or `.textContent()` sampled once:
that is the flake #85 was about, and the same trap in a different shape is what
made this test hang instead of fail.

Three local runs, 7 passed each, 1.0s for this test against the 60s it used to
burn. Each new assertion confirmed to bite by inverting it.

Co-authored-by: Michael <0xMMA@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
0xMMA added a commit that referenced this pull request Oct 5, 2026
…104)

* fix(shell): show the version with exactly one "v" in the sidebar

Release builds are stamped with the git tag (v3.6.0), so the template's
hardcoded "v" produced "vv3.6.0". versionLabel() (core/version-label.ts)
now adds the prefix only to a bare version number: tags and git-describe
output keep their own "v", a dev build shows "dev" rather than "vdev",
and a lone commit hash from `git describe --always` stays as it is. The
About tab uses the same label, so both places read alike.

Closes #21

* fix(settings): stop offering AWS Bedrock until it works

Bedrock is a stub that only returns "not yet supported", yet it sat in
the Active Provider dropdown with its own key field. It is gone from
both, and README, CLAUDE.md and the website no longer list it. The Go
stub and the settings value stay so #23 can bring it back.

A settings file that already names bedrock is left as it is: switching
it would send the user's text to a service they never chose. Settings
and Pyramidize both show "Choose a provider" with a note that AWS
Bedrock is not available yet. Pyramidize also stays disabled, skips the
key banner and model probe for it, and so never reaches the raw
"unsupported provider" error. Picking any provider clears the note.

Closes #22

* fix(settings): remove the unstyled light and system themes

Only the dark theme is styled. Choosing Light dropped PrimeNG into its
unstyled light mode, and System did nothing. The theme control is gone
(a dropdown with one entry is not a choice), and applyTheme() keeps
.app-dark whatever theme_preference says, so a value saved by an older
version cannot unstyle the app either. The field stays in the settings
model and is saved unchanged, for the light theme in #25.

The dark-mode E2E spec gains a test that serves a stored "light" through
the Wails runtime endpoint and asserts the page stays dark with no theme
control.

Closes #24

* fix(settings): keep toggle switches at full size beside long hints

The Sensitive Logging row puts a long hint next to its switch, and the
flex row squeezed the switch (30px at 1280px wide, 10px at 600px), so
the knob slid past the track's edge. The switch no longer shrinks; a
Playwright test at a 600px viewport asserts every toggle row's switch
keeps its 40px width.

Closes #26

* fix(ui): put .app-dark on <html> so dark tokens reach components

PrimeNG declares component tokens on :root as aliases of the theme
tokens (the select panel's background is var(overlay background)), and
an alias resolves on the element that declares it. With .app-dark on
<body>, <html> never saw the dark theme, so every aliased token came out
light: the disabled Pyramidize global-instruction input (slate-200),
every p-select dropdown panel (white), tab bars, and the dim slate key
labels on the AI Providers tab.

The class now sits on <html> (index.html, applyTheme). The existing
overrides in styles.scss are kept so no colour that was right changes;
the card background moves to :root.app-dark so PrimeNG's own :root card
token cannot raise the settings card off the page. Disabled pInputText,
pTextarea and p-select keep a dark zinc-900 surface with muted text,
rather than Aura's zinc-700 block. Two dark-mode E2E tests pin the
disabled-input and dropdown-panel colours.

Closes #28

* fix(pyramidize): keep Apply secondary until an instruction is typed

Apply wore the same accent as the Pyramidize button even while it was
disabled, so a button that could do nothing competed with the page's
main action. It is now secondary while the global instruction is empty
or whitespace, and turns primary as soon as the user types one.

Closes #29

* fix(pyramidize): keep an unavailable provider out of canvas refinement

With a saved AWS Bedrock provider, the Pyramidize button was already
guarded, but the Editor canvas is editable before any Pyramidize:
typing a global instruction enabled Apply, and Apply, Ctrl+Enter or the
selection bubble ended in a raw "unsupported provider: bedrock" error.
Apply now stays secondary and disabled, the bubble's Apply is disabled,
and both methods return early. The note on the page now points to
Settings, since a choice made on the page lasts for the session only.

Also: the settings spec's no-AWS-key test asserts the provider keys that
must be there, so an unrendered tab cannot pass it, and the lookup uses
Object.hasOwn so an inherited property name cannot match.

Refs #22

* feat(settings): show and switch the provider in use on AI Providers

Every provider card on the AI Providers tab now says whether KeyLint
uses it and offers a one-click switch. A provider that cannot work yet
stays selectable and its card says why. The General tab's dropdown is
replaced by a read-only pointer to the tab where the choice is made.
An environment-supplied key now names its variable on the card.

* test(e2e): cover switching providers against a fake settings backend

* refactor(settings): place provider card import clear of #31's lines

* fix(settings): apply a save in memory only once it is on disk

Save swapped the live settings before writing the file, so a failed
write still switched the provider for Fix and Pyramidize while the
settings screen put its marker back. Also reword the missing Anthropic
key error, which pointed at the removed Active Provider dropdown.

* fix(settings): harden the provider switch after review

Keep presets added this visit, hold Save and Reset during a switch,
drop a stale Ollama warning when the URL changes, move focus to the
marked card or the AI Providers tab, and word the summary so it does
not promise more than Pyramidize's own session choice delivers.

* feat(settings): add SetActiveProvider and write settings atomically

SetActiveProvider saves the provider alone under saveMu, validated
against the providers the screen offers. Settings are written to a temp
file and renamed over settings.json, so an interrupted write can no
longer leave a truncated file that stops the app from starting.

* feat(settings): switch provider alone and let Pyramidize follow it

Use this calls SetActiveProvider, so other pending edits stay pending.
Switch, Save and Reset lock each other; Save shows its errors. The
marker moves once saved, focus goes to the card heading and a live
region announces it. Pyramidize re-adopts the Settings provider on every
visit unless the user picked another there, which it now shows as a
session override with a way back.

* fix(settings): lock single-field writes and retry a held rename

settings.Update holds saveMu from read to write; SetActiveProvider,
CompleteSetup and the preset and threshold writers use it, so none can
undo a switch landing meanwhile. On Windows the final rename retries
for about 2 s while another process holds settings.json open.

* fix(settings): tidy switch focus, announcements and session override

Pyramidize drops an override once Settings agrees with it and treats a
pick forced by an unusable Settings provider as no override. A failed
switch is announced once and focuses the error when no card is in use.
A switch refreshes the target's status and clears a stale Save error.
Specs reset Pyramidize's module state, so test order no longer matters.

---------

Co-authored-by: Michael <0xMMA@users.noreply.github.com>
0xMMA added 4 commits October 8, 2026 23:05
Brings in settings atomic writes and settings.Update, provider switching
(#104), the dev channel (#107) incl. workflows, the shell footer (#106),
Go 1.27 and the CI apt fixes. Conflicts: shell keeps the subscription
list and silent-fix guard next to the dev-channel update title; wails
service keeps setShortcutPaused next to the dev-channel calls; settings
imports ToggleSwitch and SliderModule. Bindings regenerated. E2E settings
stubs gain the shortcut fields the backend now returns.
Nothing emitted settings:changed, so the hot-reload never ran and a new
combo needed a restart. Settings now calls an OnSaved hook after every
write; main.go re-applies the shortcuts when they changed, installing
the hook if startup registration had failed. Save refuses a shortcut
change the hook cannot use (unparseable, no Ctrl/Alt/Win, duplicate,
bad mode or delay), load() replaces such values from the file, and the
shortcut_key migration is dropped: Ctrl+G was hard-wired up to
v4.5.0-beta, so the legacy field never held a live value.
The shell navigates to Pyramidize on the shortcut, but the page
subscribed only after its async setup, so the event was already gone
and the page opened empty. WailsService keeps the shortcut pending
until a page takes it.
Leaving the page or switching mode while recording left the hook paused
until restart. Shift+digit now records the digit, not the symbol.
@0xMMA
0xMMA merged commit 613798f into main Oct 9, 2026
5 checks passed
@0xMMA
0xMMA deleted the feature/shortcut-double-press branch October 9, 2026 18:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shortcut flow broken on Windows — no single/double distinction, focus, and routing issues

1 participant