Skip to content

feat!: evolve Flash modes, plugins, and dev tooling - #8

Merged
aymericbeaumet merged 341 commits into
mainfrom
ab/dev
Oct 18, 2026
Merged

aymericbeaumet merged 341 commits into
mainfrom
ab/dev

Conversation

@aymericbeaumet

@aymericbeaumet aymericbeaumet commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Consolidates the ab/dev work into one PR: keyboard modes and mappings, hints and grid behavior, plugin actions, status and popup surfaces, the inspector/help experience, and release and development workflows.
  • Refreshes the pinned development environment through mise: Python, Rust with rustfmt and clippy, Node, pnpm, uv, and the compatible Zig release. CI uses the same tool versions and locked installs. SwiftPM, Cargo, pnpm, npm, and Python dependencies are updated and locked.
  • Updates the Ghostty source pin, Electron fixture, and tmux oracle to 3.8. Adapts status formatting to the new tmux behavior and updates Tokio AsyncFd registration for current Rust dependencies.
  • Loads the full retained clipboard history in bounded pages, exposes the retained count, and documents the configurable 1–10000 entry limit (1000 by default). Improves Help's connection indicator, sidebar labels, Escape behavior, and shortcut sheet. Connects onboarding recommendations, plugin and config origins, and config provenance to their related pages; replaces repeated TOML buttons with copy icons.
  • Restores the flashlight command caret after a late focus handoff during rapid NORMAL/INSERT transitions. Makes the status shutdown test accept a process-group ID reused by the OS.
  • Ranks flashlight matches by tier before source precedence, and lets Return and Escape end DRAG and VISUAL with their documented actions.

Test plan

  • swift format lint --strict --recursive Sources Tests Package.swift
  • ./Scripts/check-guardrails.sh
  • swift test (2,042 tests) and ./Scripts/test-plugins.sh --lane all
  • ./Scripts/test-status-format-oracle.py (1,201 cases)
  • Inspector Svelte check, 43 frontend tests, and rebuilt release resource; Electron fixture npm ci
  • uv run --locked mkdocs build --strict
  • Live HTTPS fetch in the feed plugin sandbox after the TLS update
  • ./Scripts/install.sh --dev; one signed resident and successful flash hints_dismiss dispatch
  • Active flash.toml passes flash config_check; resident reports zero config diagnostics on the installed a56c920 build
  • Live clipboard API returned every retained entry. The rendered status bar contained aggr, Cld, Cdx, Grk, CPU, MEM, NET, and BAT once each, with no FEED label.
  • GitHub CI on the latest commit (14 passed; expected skip/neutral checks)
  • Greptile code review: requested; the bot says its trial has ended and needs reactivation
  • Electron GUI integration in the unlocked macOS console

No other PR needs consolidation: PRs #1–#7 are already merged, and this is the only open PR.

The core hard-coded ten terminal bundle ids (TerminalBundles) to decide
where Command chords would type text, where pixel wheels are refused and
what `only_terminals` matches. No protocol identifies a terminal
emulator, so the list is now data: a manifest's `terminal_emulators`,
unioned across plugins into TerminalEmulators when each plugin snapshot
publishes (before the snapshot is visible, so selectors resolved against
it see the same set). The bundled manifest-only terminals plugin
declares the common emulators; a user adds one with a plugin of their
own. The source registry asks the declared set on each activation check
instead of freezing a copy at init.
WebBrowsers listed 22 browser bundle ids, and the core checked them (or
a com.apple. prefix) to decide how to treat an app's accessibility.
AppTraits now reads what an app is from its bundle, once per app and
cached: a web browser declares the http and https URL schemes; Gecko
ships Contents/MacOS/XUL; Chromium (browsers, Electron and CEF apps)
ships renderer helper apps; Flutter ships FlutterMacOS.framework.

- a browser's web areas are pages (semantic allowlist); any other app's
  are its own interface, where pressable containers are targets.
- the enhanced-UI wake goes to the runtimes that gate their tree on it,
  Chromium and Flutter; AppKit, SwiftUI and UIKit apps, Apple's or not,
  never get the flag. It used to reach every non-com.apple app.
- Gecko's scoped accessibility (GeckoAccessibility, was
  FirefoxAccessibility) and the window mover's exception key on the
  engine, so every Gecko build is covered, not three bundle ids.
- AccessibilityWaker (was ChromiumAccessibilityWaker) also warms the
  traits cache off main for running and launched apps.
- the walker threads one WalkEnvironment instead of five parameters.
AppMonitor excluded com.apple.Notes by bundle id from background model
warming and observed it with the reduced notification set. The exclusion
is now a manifest field, `on_demand_hints`, unioned across plugins like
`terminal_emulators`; the bundled defaults plugin declares Notes. Both
declared sets share one DeclaredApps registry type, replaced whole on
each plugin snapshot and readable from any thread.
Every hint activation reassigned overlay.overlayConfig from the loaded
config, and its didSet unconditionally bumped statusBarLayoutRevision
(invalidating the memoized status-bar layout) and re-stroked the active
window border. The value only changes at launch and on config reload,
so the per-activation pushes are gone and the didSet ignores an equal
value.
A keydown after an app switch no notification reported reconciles the
frontmost app from inside the synchronous tap callback, and refreshing
its effective mappings reached Register/UnregisterEventHotKey there.
apply(mode:) now swaps the table the tap reads synchronously and leaves
the Carbon registrations, which only matter without the tap, to the
next main turn, coalesced.
Refresh reasons were strings that drove control flow: every AX
notification built "ax:<name>", then hasPrefix("ax:") and string
compares against "maintenance" and "queued" decided throttling,
priority and speculation. ModelRefreshReason carries those decisions as
properties; the log string is built only when logged.
normalModeCaptureRecoveryRecaptureToken was never set, so
cancelNormalModeCaptureRecovery always returned early at its five call
sites. normalModeCaptureRecoveryShouldRetry, its delay table and
normalModeShouldRecaptureAfterActionDispatch (a plain wrapper) were only
called from tests; commandLineEntryIsAllowed always returned true and
commandLineExitMode always NORMAL. The wrapper's test now exercises
normalModeShouldOwnKeyboardInput, which it wrapped.
filter(prefix:hints:) keyed a fresh dictionary by each chip's label, but
labels are unique within a set, so every visible chip paid a string hash
and a miss on every keystroke. It now renders the visible labels
directly, and builds the visible-index set only while debug bounds are
drawn.
The decision lived in overlapping branches of keyboardTapShouldSwallow,
with the native-owner condition spelled two ways and secure input
checked at three sites. KeyboardCaptureTap.decide now maps state to a
Decision (pass, swallow, swallow if an INSERT chord is mapped, swallow
if a native-surface key is mapped), pinned by a table test; the tap
callback evaluates only the effects the decision asks for, in the same
cheapest-first order.
Five copies read NSScreen.screens for the primary display's height,
including the AX walker and the scroller, which run off main (gg / G now
do): NSScreen is main-affine. ScreenSpace caches the height, reads it on
main at launch and on every screen-parameter change (its observer is
registered first), and serves every thread from the cache.
The previous-tab action went over the wire as tab_prev while the verb
and every sibling spell previous out (tab_move_previous, pane_previous),
and the host accepted both spellings when reading a manifest's
`actions`, an alias the project forbids. The wire name is now
tab_previous in the host and the tmux plugin; capabilities derive from
SourceAction.byWireName instead of a hand-kept string switch, and a
manifest declaring an unknown action is rejected at load.

BREAKING CHANGE: third-party plugins must declare and handle
`tab_previous` instead of `tab_prev`.
AppClickDecision bundled four Bools (release capture, enter INSERT,
suspend for a native surface, dismiss hints) of which three combinations
were reachable, and MenuBarClickDecision always suspended. They are now
AppClickDecision { ignore, suspendForNativeSurface(dismissHints:),
handOffToInsert } and PointerDecision.menuBar(dismissHints:); the handler
switches over them instead of testing each flag.
The 1...999 repeat-count bound was spelled in three places, the
post-action recapture (force when focus may move, longer ramp) was
copied in the key-window and mapping dispatch paths, and pointer mode
kept a private copy of suspendNormalCaptureForNativeSurface. Each now
exists once: RepeatCount.clamp, recaptureAfterNormalAction and the
shared suspend.
A status-bar hover preview only closed when the pointer left its
segment, so it lingered over whatever the user clicked or switched to.
A press in another app, the desktop or the menu bar (the global pointer
monitor), an app switch and a focused-window change now close it, and
cancel a terminal popup still waiting out its hover dwell. The hover
gate keeps it shut until the pointer leaves the segment. Focused
popups already close when they lose key; pinned terminal popups stay.
The HTTP inspector accepted any request from a loopback peer, so a web
page could rebind its own hostname to 127.0.0.1 and read /state (the
clipboard history, hint labels) and /logs through the browser. It now
serves only requests whose Host names this listener (127.0.0.1,
localhost or [::1] on its port), and its log sink follows the configured
level instead of forcing every trace message on hot paths to be built.

Logs no longer carry what the user typed: the tap-to-route latency line
dropped its key code, NORMAL's input trace its characters, and a dropped
hint commit its target's accessibility label (warn level, on disk by
default). The tap must never record keys (hard constraint 2).
…cal subcommands

A focus change emitted core:apps.changed with the full running-apps
list even when no app launched or quit, and the windows plugin re-walked
every app's accessibility tree on each: 2,587 warm refreshes, about
600 s of walking over five hours of logs. The event now fires only when
the set of (pid, bundle id) changes; the cached list plugins replay at
startup still tracks every snapshot.

Commands matched their registration case-insensitively but forwarded the
typed case, so `:processes Refresh` reached the plugin as an unknown
subcommand; the plugin now gets the names it registered.
- A failed source action keeps the plugin's reason and the source that
  claimed it (SourceActionResult.failureReason/source), logged with it.
- Every plugin request completion logs its id, method, elapsed time and
  outcome at debug; timeouts and late replies carry the id; perform is
  no longer exempt from the slow-request warning.
- A malformed reply fails the request it answers at once instead of
  leaving its caller to wait out the deadline.
- Plugin stderr is logged as whole, capped lines at a bounded rate, with
  a count of what was suppressed; exits say signal or exit status.
- Per-plugin CPU% was about 42x too low on Apple silicon: rusage CPU
  time is Mach ticks, now converted (MachTime).
- A run-loop observer reports every main-thread busy stretch over
  250 ms at any log level (the ping watchdog stays debug-only, and is
  now toggled after the inspector sink changes, not one reload late).
- FlashLog: `fields` is lazy like the message, sinks take a minimum
  level, `error()` exists, and `emits`/`wouldEmit` are one function.
A bare Escape while a status-bar hover preview shows now closes it in
either base mode and is swallowed, so the app doesn't also get it; the
command line and a hint session keep their own Escape, and the key is
not routed on to a mapping or the NORMAL interpreter. enter_normal_mode
closes the preview too. Decided in KeyboardCaptureTap.decide
(closeEphemeralPopup), pinned by its table test.
A routed key, a native hotkey, a physical click Flash acts on, a CLI
verb or a submitted command line now starts an interaction with a short
trace id (Trace), carried on every log line it causes: dispatch, the
source-action chain (re-entered on each source's completion), delayed
key sends, and plugin requests, whose envelope carries `trace`. The SDK
runs each request handler inside that id and stamps it on the `log`
notifications the handler emits, so plugin lines join the host's:
`rg '"trace":"<id>"' flash.log` reads one interaction across processes.

- protocol.json pins the id pattern; Swift and Rust parity tests check it.
- The inspector serves /logs?trace=<id> and /traces (recent interactions:
  origin, duration, lines, worst level, participating sources), computed
  from its log ring on demand.
- docs/observability.md describes the log format, tracing, inspector
  endpoints, stall detection, plugin diagnostics and privacy rules.
A plugin whose only resident surface is `status` (its `listen` feeding
those segments) is now resident only while the enabled status bar, its
options or a popup references one of its segments; otherwise it is
on-demand and spawns on its first perform. A config reload that starts
showing it spawns it; one that stops keeps the running process.

With the bar off (the default), cpu, memory, disks, power, caffeinate,
feed and aiproviders no longer spawn at launch.
Every request envelope now carries `deadline_ms`, what is left of the
host's wait. The Rust SDK drops a `search` or `hints` handler still
running a tenth of it (at most 50 ms) before the deadline, with the
subprocesses and host calls it awaits, and answers `deadline exceeded`
while the host still listens, logging it under the request's trace.

`host.ax_snapshot` takes an optional `deadline_ms`: past it the walk
answers with the nodes reached so far and `truncated: true`. The vscode
plugin bounds its walk to 300 ms so its hints fit the 500 ms deadline.
A bang with a query opened the provider's page, then 2.5 s later pressed
Return in whatever app had focus — a terminal the user switched to would
run its pending command line. `host.open` now names the app a URL was
handed to (`bundle_id`), and the plugin presses Return only while that
app is still frontmost.
Every five minutes the plugin copied Firefox's and Chrome's databases,
queried them and republished the catalog. A refresh now stamps each store
file (length, mtime) and the recency window's day, and does nothing when
all match the published catalog's; a database whose files are unchanged
reuses its private copy instead of copying it again.
`caffeinate -w <plugin pid>` exits with the plugin, so a crash or a
SIGKILL past the shutdown grace no longer leaves an ownerless assertion
keeping the Mac awake. `on`/`toggle` with a minutes argument that is not
a whole number (`-5`, `1h`) now fails with the usage line instead of
silently keeping the Mac awake indefinitely.
The browsers and firefox plugins scripted every running browser on any
focus change, app launch or quit — hundreds of milliseconds of
AppleScript per event, for events in unrelated apps. The SDK's new
`AppWatch` passes only focus moving into, within or out of a watched
app, one launching or quitting, a change in their running instances, and
a flashlight session opening; the 10 s poll catches the rest.
A plugin polling a slow or failing program logged `[plugin] subprocess
slow` on every tick (1,583 lines from the browsers plugin). Each kind of
warning — per program, slow or timed out — now logs once a minute, with
`suppressed` counting the ones held back.

A failed osascript logs `[plugin] osascript failed` with the AppleScript
error number alone (never its message), which is what shows the browsers
plugin's refreshes failing with -10004.
`label` carries the same rotating headlines and still prefix as
`summary`, without the outbound arrow or inline preview; title and
domain form one link to the item. Binding it hands hover to the
surrounding template, so a configuration can wrap it in its own popup
(a terminal running any feed reader).
A full-screen program keeps its last row for messages and prompts and
leaves it blank otherwise (newsboat's message line). A terminal popup's
hover preview now clips that row while it is empty, as the pager path
already did; a focused or pinned popup keeps it for `/` input and
errors, and a row with text is never clipped.
A committed left-click on a link or a named range action already
answered why the popup was open, so the hovered popup closes and stays
shut until the pointer leaves its region. Pinned popups and clicks with
no handler are left alone.
A focused terminal popup stayed up after the user went back to the app it
covered: Flash asked to activate with `NSApp.activate()`, which macOS 14+
declines while another app is frontmost, so the covered app stayed active
and bringing it forward again (a click into it, or `open` raising it for a
link) posted no app switch for Flash to see. Focusing a popup now
activates Flash from the frontmost app (`activate(from:options:)`), so any
app coming forward is an app switch and the popup closes on it, leaving
TERMINAL. Verified on the resident: with Firefox frontmost, a focused btop
popup makes Flash frontmost and closes when Firefox is raised again.
Add `flash gesture --fingers=2..5 --motion=<motion>`, which performs what
that trackpad gesture does under the default macOS settings: secondary
click, smart zoom, zoom, rotate, history, Look Up, Mission Control, App
Exposé, Spaces, the app launcher and show desktop. Zoom, rotate and smart
zoom post real gesture events; the Dock surfaces use
CoreDockSendNotification, resolved at run time.

Add the `look_up` app action (Control-Command-D by default) and
`--gesture=look_up|smart_zoom` on mouse_target, mouse_bigram and
mouse_grid. In NORMAL a count before a mouse finisher is a finger count:
`2f` secondary-clicks, `3f` looks up and `2df` smart-zooms at the pick.

Mission Control, including `system_key --key=mission_control`, now asks
the Dock: the posted Mission Control key reached nothing on macOS 26.
The login-shell resolver waited for its shell's exit and output on two
blocks of the global concurrent queue. With that pool saturated, a busy
launch or a parallel test run, neither block ran and the resolution timed
out after ten seconds, keeping the seeded environment. Exit now arrives
through the process's termination handler and stdout drains on a
dedicated thread.
Flash takes activation for its own typing surfaces, and leaving one does
not always give it back: right after launch Flash was frontmost while
Firefox looked focused. In INSERT every key the tap passes, Command
chords included, then reached Flash instead of the app. Flash now hands
activation back whenever it shows INSERT or becomes active in it, unless
About, a hint session or a physical click still owns activation.

`flash status` (schema 4) adds what decides a chord's fate:
frontmost_app, flash_active, chord_routing (what the tap does with a
modified chord now) and held_modifiers (a stuck modifier rides every
keystroke). Running it while a shortcut fails pins the cause.

BREAKING CHANGE: `flash status --json` reports schema 4 with four new keys.
Pin mise tools and lock Swift, Rust, JavaScript, and Python dependencies. Refresh Ghostty, Electron, and tmux oracle baselines; adapt status formatting and Tokio registrations to the new upstream behavior.

BREAKING CHANGE: status formatting now follows tmux 3.8 for anchored substitutions and shell quoting.
@aymericbeaumet aymericbeaumet changed the title feat(mode): hold the leader to enter hyper feat!: evolve Flash modes, plugins, and dev tooling Oct 15, 2026
@aymericbeaumet

Copy link
Copy Markdown
Owner Author

@greptileai please review the latest changes on ab/dev.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

Pin rustfmt and clippy alongside Rust so fresh CI runners can execute the plugin gate with the same toolchain as local development.
@aymericbeaumet

Copy link
Copy Markdown
Owner Author

@greptileai please review the latest commit (0e71fce) on this PR.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

Page clipboard history within the plugin wire limit and expose the retained count through the inspector. Show a compact connection indicator, keep navigation labels on one line, and make Escape and the shortcut sheet easier to use.
Open linked onboarding items directly, connect configuration origins and provenance to the live editor, and replace repeated TOML buttons with compact copy controls. Remove the redundant Flash Help breadcrumb on Home.
@aymericbeaumet

Copy link
Copy Markdown
Owner Author

@greptileai please review the latest commit (edbe2ee) on this PR.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@aymericbeaumet

Copy link
Copy Markdown
Owner Author

@greptileai please review the latest commit (f14de76) on this PR.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

The flashlight sorted the default pool by a hardcoded category ladder
(tmux windows > running apps > installed apps > browser tabs) before match
score, so an exactly typed tab title lost to every app the query matched
partially unless the query happened to be a title prefix.

Rank by how the query hits the row's own name first (exact > prefix > word >
substring > secondary/fuzzy), then by source precedence, then by score and
frecency. The ladder is gone: the within-tier order now comes from the
manifest `sources[].priority` (tmux windows `important`, apps and tabs
`high`), the alive bonus and `[flashlight.precedence]` overrides. Multi-word
queries match title words in any order, and `app_open --name` ranks plugin
locations with the same scorer instead of taking the first substring hit.

BREAKING CHANGE: `[flashlight.precedence]` weights now order rows inside one
match tier only; the strict category ladder no longer exists.
A mapping on a named key kept whichever alias spelled it (`<esc>` stayed
`esc`, `<return>` stayed `return`) while the interpreter emitted `escape`
for Escape and nothing at all for Return, keypad Enter, Delete, the arrows,
Home, End and the Page keys. The DRAG table's Return (drop) and Escape
(cancel) and VISUAL's Escape therefore never fired: the key was swallowed
and the only way out was the NORMAL chord. Every named key now has one
atom, shared by the mapping parser and the interpreter, and keypad Enter is
Return to both.

Entering DRAG is now the `enter_drag_mode` verb (`--secondary`, `--middle`),
a toggle that grabs or drops, and it ships unmapped: the `D` and `cD`
defaults are gone. A hold from INSERT (an all-mode chord) takes the keyboard
as NORMAL entry does and its release lands in NORMAL.

A pointer move warps at once on every path, so the next keystroke of a held
gesture reads the pointer where the previous one left it.

BREAKING CHANGE: `D` and `cD` no longer enter DRAG; bind `enter_drag_mode`.
@aymericbeaumet

Copy link
Copy Markdown
Owner Author

@greptileai please review the latest commit (a56c920) on this PR.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

Clear inherited shortcut modifiers from held mouse events so Control-based drag shortcuts remain primary drags.

Show plugin details in a dismissible dialog, simplify the verbs guide within the shared docs layout, and correct help that implied colon was bound by default.
Add Vim-style registers, clear successful drag yanks, and harden mark restoration. Show register state in help and keep clipboard history in the resident.

BREAKING CHANGE: VISUAL mode and the clipboard plugin are removed. Configure clipboard history under [clipboard].
Rename the mode, command, config scope, status, help, and inspector. Register Command-Control-H by default and gate the toggle to NORMAL, INSERT, and HOLD.

BREAKING CHANGE: enter_drag_mode, [mode.drag], --mode=drag, and the drag status value become their hold equivalents.
@aymericbeaumet
aymericbeaumet merged commit 5514d4c into main Oct 18, 2026
16 checks passed
@aymericbeaumet
aymericbeaumet deleted the ab/dev branch October 18, 2026 18:29
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.

2 participants