Skip to content

feat(fix): silent hotkey fix feedback — tray toast, source-window paste - #110

Merged
0xMMA merged 4 commits into
mainfrom
fix/silent-fix-feedback
Oct 10, 2026
Merged

0xMMA merged 4 commits into
mainfrom
fix/silent-fix-feedback

Conversation

@0xMMA

@0xMMA 0xMMA commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

What

Since #31, Ctrl+G runs the silent fix on any route with the window hidden — and a failure (missing key, signed-out CLI, timeout, cut-off) only went to the log. This PR gives it feedback that does not get in the way, and folds in #93, which is the same path.

  • Success stays silent. While a fix runs, the tray icon carries a small amber dot and its tooltip says "KeyLint — fixing…". Nothing pops up, nothing takes focus.
  • A failure is one small, silent toast from the tray, the reason in plain words. Clicking it opens KeyLint where it can be fixed: AI Providers for key / CLI / model problems, otherwise the Fix page, showing the reason, the full error and the text the fix ran on (ready to retry).
  • Never more than one toast per key press, and an identical toast is not repeated within 30 s, so pressing Ctrl+G a few times with the key still missing gives one note, not a stack.
  • Paste goes back only to the window the text came from, and only while it still has focus — checked after the clipboard's settle delay, immediately before Ctrl+V, and including the focused control where Windows exposes one (native fields, Office; browsers/Electron draw all fields in one child, so there only the top-level window is compared). If the user switched windows or closed it, nothing is pasted anywhere; the fix stays on the clipboard and the toast says so.
  • Nothing selected → nothing sent, nothing pasted. If Ctrl+C leaves the clipboard unchanged (nothing selected, or an elevated window that silently drops our input), the old clipboard content is not fixed — it could be the previous fix (double paste) or something private. One toast: "Nothing selected — Select text and press the fix shortcut again."
  • One fix at a time. A second press while one runs is dropped (no second fix, no copy, no second paste). The guard is a per-run token: a run released by the safety timer that comes back later cannot paste, notify, or release a newer run (leftover from feat(shortcut): single/double press detection for Ctrl+G #31's review).
  • bug(enhance): timed-out Fix attempt is retried into a near-certain second timeout #93: a timed-out attempt is no longer retried when what is left of the deadline cannot fit another attempt, and the timeout message says what to do: "Claude took too long to answer — try a shorter selection or a faster model".
  • Hook channel (feat(shortcut): single/double press detection for Ctrl+G #31 review): the Windows hook's thread did a blocking send into a 2-slot channel. While the consumer was busy (a Pyramidize press copies for up to 1 s), a third press would block the hook thread — stalling every keystroke on the machine, and Windows silently unhooks a low-level hook that keeps timing out. Now 16 slots and a send that never blocks (drops and logs when full).

Architecture change: the silent fix runs in Go

The read → enhance → write → paste pipeline moved from ShellComponent (hidden webview) to internal/features/silentfix. Reasons: the source window has to be captured at the key press and held per run; the failure reason is a Go error type; the toast is an OS call; and the guard must not depend on timers in a hidden page. The shortcut:fix event is gone; the frontend only hears silentfix:open when a toast is clicked.

To decide on reasons without matching strings, llm and enhance now return typed errors (StatusError, TimeoutError, ErrNotSignedIn, ErrCLINotFound, ErrOutputLimit[Thinking], enhance.MissingKeyError). Their text is unchanged except for timeouts.

Mechanism choice: toast via go-toast, not a tray balloon, not the Wails service

Option Verdict
Tray balloon (Shell_NotifyIcon + NIF_INFO) via the existing tray Wails v3 alpha.72 has no balloon API. A balloon must name the tray icon's own hWnd/uID and its click arrives as NIN_BALLOONUSERCLICK in that window's procedure (NOTIFYICONDATAW). Both are private in Wails (pkg/application/systemtray_windows.go: hwnd/uid unexported, wndProc handles no balloon messages). Not reachable without patching Wails.
Wails notifications service (pkg/services/notifications) Works, and uses go-toast underneath — but hard-codes the default notification sound (notifications_windows.go: no Audio, so go-toast's applyDefaults picks Notification.Default), falls back to spawning PowerShell when COM fails, and a service startup error would be fatal to the app.
go-toast v2.0.3 directly (already a dependency of Wails) Chosen. Silent (Audio: silent), short, activationType="foreground" so a click reaches the app; pushed over COM only, no PowerShell; setup failures are logged and turn toasts off, never stop the app.

Registration (internal/features/notify/notify_windows.go): the AppUserModelID registry method for unpackaged apps — HKCU\Software\Classes\AppUserModelId\KeyLint with DisplayName, IconUri, CustomActivator, plus HKCU\Software\Classes\CLSID\{KeyLint GUID}\LocalServer32 — the same method go-toast and Wails use. No Start-menu shortcut or AUMID on the shortcut is needed, so nothing changes in the NSIS install step; it works identically for the NSIS install, dev-channel builds (same installer) and an exe run from a build folder. Values are rewritten on every start, so the paths follow whichever exe ran last. The activator class is registered at startup on its own MTA thread, so a click on a toast left in the Action Center by an earlier session reaches the running instance instead of starting a second one; -Embedding (what COM appends when it has to start KeyLint) is accepted as a flag. KeyLint has its own activator GUID — go-toast's default is shared by every app that keeps it. The NSIS uninstaller now deletes both keys.

Toasts never contain user text (Windows keeps them in the Action Center database on disk); the text stays in memory for the Fix page. Linux: log only.

Behaviour per outcome

Outcome Toast title — body Click opens
Fixed, source window still focused (none — pasted) —
Fixed, user switched windows Fixed, not pasted — You switched windows — the fix is on the clipboard. Fix page (input + result)
Fixed, source window closed Fixed, not pasted — The window you fixed text in was closed — the fix is on the clipboard. Fix page
No API key Fix didn't run — No API key for Anthropic. / OpenAI. AI Providers (with a one-line note why)
CLI signed out Fix didn't run — Claude Code CLI isn't signed in. AI Providers
CLI not installed Fix didn't run — Claude Code CLI isn't installed. AI Providers
401 / 403 / 404 Fix didn't run — Anthropic didn't accept the API key. / This key can't use the chosen model. / The model wasn't found — check it in AI Providers. AI Providers
Timeout Fix took too long — The model took too long — try a shorter selection or a faster model. Fix page
Cut off Fix was cut off — The answer was cut off — try a shorter selection. Fix page
Cut off while reasoning Fix was cut off — The model used its answer on reasoning — pick one that doesn't reason first. AI Providers
429 (also when its Retry-After outlasts the deadline) / 402 / 408 / 413 / 5xx / unreachable Fix didn't run — Rate limited — try again shortly. / The … account is out of credit. / Anthropic timed out — try again. / The text is too long for the provider — try a shorter selection. / Anthropic isn't available right now. / Couldn't reach the AI provider. Fix page
Nothing selected (clipboard unchanged by the copy, or blank) Nothing selected — Select text and press the fix shortcut again. Fix page
Panic below the run (SDK, Win32) Fix didn't run — Something went wrong — click for details. (recovered; guard released) Fix page
Second press while a fix runs (none — dropped, the tray dot is already showing) —
Run stuck past 135 s (backstop) Fix took too long — It didn't finish — try again, or a shorter selection. Fix page

Decision on how a slow fix feels (#93's open question)

Owner's intent: "es sollte nicht stören, also bestenfalls ein kleines Tray-Popup". Implemented as: wait (up to the existing 120 s) with the tray dot as the only sign, paste only into the source window. If the user has moved on, KeyLint does not pull the focus back to the source window to paste (as Pyramidize's SendBack does): they may be typing elsewhere, and a focus switch mid-word sends their keystrokes to the wrong place. The fix waits on the clipboard and the toast says so. Recorded in internal/features/silentfix/service.go and the maxTokens comment in enhance/service.go.

Intent answers (decided in line with the owner's "should not disturb, no paternalism")

  1. Ctrl+G with nothing selected does not fix or paste old clipboard content (privacy + double paste); one small toast "Nothing selected". The cost: an app that takes more than 1 s to put a copy on the clipboard gets a false "Nothing selected".
  2. A second identical notice within 30 s stays quiet (no toast piles) but is logged. A click on the toast on screen opens the latest run's text.
  3. When a toast opens AI Providers, the tab shows a short, dismissible one-line note saying why ("Fix didn't run: No API key for Anthropic."); it goes away when dismissed, when a key is saved, when the provider is switched, or when "Check again" finds the CLI signed in. A second click works even when the URL is already /settings?tab=providers.

Closes #93

Tests

  • Go: llm — retry decision table, timed-out attempt not retried into the deadline (Anthropic + OpenAI, real HTTP), retried when there is room, typed errors keep their wording, CLI signed-out / timeout / not-installed types. silentfix — success pastes silently, second press dropped (one copy, one paste), late run after the safety release cannot clear a newer guard or paste, switched window / closed window not pasted, empty selection skips the model, repeated identical failures shown once until the quiet period ends, notice lookup for a click, decidePaste and describe tables. tray — busy icon. shortcut — deliver never blocks. notify — activation routing.
  • Vitest (DOM-first): shell routes a clicked notice to AI Providers / Fix and runs no fix of its own; through the real router, Settings switches to AI Providers when already open, the Fix page shows reason + detail + text and updates in place; Fix page notice display and dismissal.
  • go test ./internal/... ✔, -race on llm, silentfix, tray, shortcut, enhance, notify ✔, go build ✔, GOOS=windows go build ✔, npm test 351/351 ✔, Playwright 74 passed / 3 skipped (the existing skips).

Windows manual test

  1. Signed-out CLI: active provider = Claude Code CLI, run claude /logout in a terminal. Select text in Notepad, press Ctrl+G. → One toast "Fix didn't run — Claude Code CLI isn't signed in.", no sound, Notepad keeps focus. Click it → KeyLint opens on Settings › AI Providers. Press Ctrl+G three more times within 30 s → no further toasts.
  2. Switch window during a slow fix: pick a slow model (e.g. Opus), select a long paragraph in Notepad, Ctrl+G, immediately click into another app and type. → While it runs the tray icon shows the amber dot; nothing is pasted into the other app; toast "Fixed, not pasted — You switched windows…"; Ctrl+V in Notepad pastes the fix; clicking the toast shows the Fix page with the text and the result.
  3. Double press: Ctrl+G twice quickly (independent mode, or wait out the double-tap delay between presses) → one fix, one paste.
  4. Close the source window during a slow fix → nothing pasted anywhere, toast "…was closed…".
  5. Missing key: Anthropic API with no key → toast "No API key for Anthropic." → click → AI Providers.
  6. Uninstall → HKCU\Software\Classes\AppUserModelId\KeyLint is gone.

Review (Opus, one round) — what changed after it

Fixed: stale clipboard fixed and pasted (above); 150 ms gap between the focus check and Ctrl+V; same window but another field; a retry-skip path that could hand the Anthropic SDK (nil, nil) (nil-pointer panic); notice IDs colliding across sessions (now carry a per-process nonce); suppressed repeats pointing the visible toast at stale text; tray busy state now posted to the main thread instead of waited on under the silent-fix lock; panic recovery in the run (guard released even mid-paste); 429-past-deadline wording; second click to AI Providers with an unchanged URL; notices only shown on their own page and dropped after 10 s if unclaimed; flaky silentfix tests (the harness took the guard release for the notification; now waits for the notice — -race -count=10 clean); 408/413 wording; "Anthropic" in toasts to match the AI Providers card; CoInitializeEx S_FALSE; fixClipboard() clears the notice; stale shortcut:fix bus type; uninstaller removes the toast icon; roadmap note.

Not in this PR

  • Second instance via COM: if the activator registration failed and nothing was pushed yet (or a toast is clicked in the first milliseconds of startup), COM starts KeyLint.exe -Embedding a second time, which installs a second hook. Unlikely; a single-instance guard (Wails SingleInstance or a named mutex for -Embedding) is the follow-up.
  • Browser tab switch inside the same window is not detected (same HWND, same focus child). "Don't paste if the user typed or clicked since the press" would cover it but is a product choice.
  • The source window is captured when the consumer takes the event, not in the hook; a fix press queued behind a Pyramidize press (which shows KeyLint) could capture KeyLint's own window. Rare.
  • Shortcut recorder Y/Z keyboard-layout issue from feat(shortcut): single/double press detection for Ctrl+G #31's review — separate follow-up.
  • Pyramidize's sendBackToWindow builds its INPUT struct with type 2 (hardware) and the wrong layout — likely why SendBack paste is unreliable. Not touched here.

0xMMA and others added 4 commits October 10, 2026 00:27
A timed-out attempt is no longer retried when what is left of the
caller's deadline cannot fit another attempt (#93). Timeouts read
"<provider> took too long to answer — try a shorter selection or a
faster model" and still unwrap to context.DeadlineExceeded. Status,
signed-out, CLI-missing, output-limit and missing-key failures get
types so a caller can act on the reason; their wording is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
The fix shortcut now runs entirely in Go (internal/features/silentfix):
it captures the source window at the key press and pastes only into
that window while it still has focus; otherwise the fix stays on the
clipboard. One run at a time, guarded by a per-run token, so a second
press neither starts a fix nor copies, and a late run cannot paste or
release a newer one. A dot on the tray icon shows a fix in progress.

A failure or an unpasted fix is one silent, non-modal toast
(internal/features/notify, go-toast with the AppUserModelID registry
method); identical toasts are not repeated within 30 s. A click opens
AI Providers for key, sign-in and model problems, otherwise the Fix
page with the reason and the text.

Closes #93

Co-Authored-By: Claude <noreply@anthropic.com>
- nothing copied (clipboard unchanged) no longer fixes and pastes the
  old clipboard: one "Nothing selected" toast instead
- the focus check now runs after the clipboard settle delay, right
  before Ctrl+V, and includes the focused control where Windows has one
- a panic in a run is recovered and releases the guard
- the retry skip can no longer hand the SDK (nil, nil); a 429 waited
  out past the deadline reads as a rate limit, not a timeout
- notice IDs carry a per-process nonce; a suppressed repeat updates the
  data behind the toast on screen
- tray busy state is posted to the main thread, never waited on
- a second click to AI Providers works with the URL unchanged, and the
  tab says why it was opened until dismissed or acted on
- notices only show on their own page and expire if unclaimed
- deterministic silentfix test harness

Co-Authored-By: Claude <noreply@anthropic.com>
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.

bug(enhance): timed-out Fix attempt is retried into a near-certain second timeout

1 participant