Skip to content

fix(ui): honor Alt/Cmd+Backspace and take the keyboard while opening - #350

Merged
forketyfork merged 7 commits into
mainfrom
fix/recent-folders-input
Aug 5, 2026
Merged

fix(ui): honor Alt/Cmd+Backspace and take the keyboard while opening#350
forketyfork merged 7 commits into
mainfrom
fix/recent-folders-input

Conversation

@forketyfork

@forketyfork forketyfork commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Two reported bugs in the recent-folders overlay, the shared cleanup they pointed at, and a test-discovery gap found while verifying the fixes. No tracking issue existed for any of these; linkage can be added during cleanup if issues are filed retroactively.

Solution

1. Resize-settle debug logging (ec9ef56)

Nothing in the resize-settle lifecycle was logged, so there was no way to tell from architect.log why a terminal entered the sweeping animation, how long the hold lasted, or why it ended. Three log.debug lines now cover it:

session 2: resize settle hold started, 80x24 -> 120x40
session 2: resize settle hold re-armed mid-sweep by a 4096-byte chunk, rearm 1/2
session 2: resize settle hold released after 1240ms (quiet), rearms=1, sweep 1400ms

The release reason comes from a new ResizeSettleRelease enum whose classifier mirrors the exit branches of resizeSettleHoldActive, so quiet (agent settled on its own), max_duration (hit the hard cap while still streaming), no_response (never reacted to the SIGWINCH) and session_gone are distinguishable. Requires [logging].min_level = "debug".

2. Recent-folders input fixes (7315020)

⌥/⌘Backspace did nothing special. The handler at recent_folders_overlay.zig matched SDLK_BACKSPACE without reading modifiers at all, so every variant deleted one byte.

The first key after ⌘O was lost. startExpanding sets .Expanding for the ~200 ms animation, but both the SDL_EVENT_TEXT_INPUT and SDL_EVENT_KEY_DOWN gates tested state == .Open. The event fell through UiRoot to runtime.zig:1803 and was typed into the focused terminal — not merely dropped.

Rather than patch the two call sites, both patterns became shared:

  • src/ui/text_edit.zig is now the single source of truth for backspace semantics (DeleteScope, scopeFromMods, backspace): ⌘ clears, ⌥ deletes a word, plain deletes one UTF-8 codepoint. A survey found four text inputs with four different behaviors; all now use it.

    Input Before After
    Worktree name 1 byte / ⌘ clear / ⌥ word shared (plain now UTF-8-safe)
    Recent folders search 1 byte only full set
    Reader search (⌘F) 1 byte only full set
    Story search (⌘F) 1 byte only full set
    Diff comment editor UTF-8 / ⌘ clear, no ⌥ full set, ~15 lines of inline UTF-8 walking removed
  • ExpandingOverlay.State.isOpenOrOpening() replaces state == .Open wherever the question is "does this overlay own the keyboard / is it visible". The worktree overlay was dropping ⌘T and ⌘1–9 into the terminal during its own expand; pill_group.zig had the same comparison spelled out six times.

startCollapsing also now reverses from the overlay's current size instead of snapping to full size first, which the mid-expand close path would otherwise have made visible.

3. Test registry gap (bab75d6)

The new tests passed without running. Zig only collects tests from files it analyzes; src/main.zig keeps an explicit registry for exactly that reason, and three files were missing from it — including gfx/shimmer.zig, whose tests had never run. Confirmed with a deliberately failing canary that the suite reported green, then by inspecting the test-name table in the compiled binary.

Registering them takes the suite from 283 to 302 tests. Since transitive reachability depends on which decls Sema happens to analyze, the rule is now "list every file with tests, explicitly", enforced by scripts/check-test-registry.sh in just lint (already run by CI).

4. Text-field editing across the app (90096f7)

Follow-up from testing the branch: the search fields had no visible caret, ⌘A did nothing, and ⌘C/⌘V were unhandled — so text could not be pasted into a field at all (which is also why the emoji step in the test plan below was untestable).

text_edit now exposes a TextInput that owns the buffer plus the state a focused field needs (caret blink phase, select-all flag) and handles the macOS editing keys. All five fields store their text in one, so behavior can no longer drift between them:

  • Blinking caret in all three search bars (recent folders, reader ⌘F, story ⌘F); the worktree name and diff comment fields keep theirs, now driven by the same clock. Reader and story request frames while their search is open, without which the caret cannot blink under idle throttling.
  • ⌘A highlights the whole field; the next keystroke, paste or Backspace replaces it. In the diff comment editor the highlight follows the wrap, one band per visual line.
  • ⌘C / ⌘V copy and paste. Pasting multi-line text into a one-line field drops the newlines; pasting into the worktree name field drops characters it would reject when typed.

The model is append-only by design (agreed with the author before implementing): the caret sits at the end and the only selection is "everything". Caret movement, partial selection and click-to-position are not included and would be a separate change.

⌘1–9 quick-select in the recent-folders picker is checked before the field's own ⌘ shortcuts, so it keeps winning.

Review feedback

Copilot flagged grep --include in scripts/check-test-registry.sh as GNU-only. Verified and replied inline: macOS ships BSD grep, GNU compatible 2.6.0-FreeBSD, which supports --include, and CI's Linux runner is GNU grep — so neither environment breaks. The duplicated-work half of the observation was real and is fixed: the count is now a loop counter instead of a second recursive grep.

5. Field overflow and emoji sizing (1017708)

Two more issues found while testing: a long pasted line rendered past the search bar's edges, and emoji were drawn enormously larger than the text around them — in fields, and also in reader mode and stories.

Both come from drawing a text texture at whatever size SDL_ttf hands back. Measured on this machine at 14 pt:

result
TTF_FontIsScalable(Apple Color Emoji) false — one fixed 160 px bitmap strike
TTF_GetFontHeight at ptsize 14 210
"hello" 30 × 17
"hi 🙂" 174 × 160
TTF_SetFontSize(emoji, 14) returns true, changes nothing

So a single emoji in a string makes the whole line ~9× the text height. The terminal never hit this because font.zig scales every rendered glyph into its cell; UI text had no equivalent.

The new src/ui/text_render.zig does the same job per line: split the string into emoji and text runs, render each with the font that owns it, scale the emoji runs to the text line height, compose into one surface. drawClippedTail draws a line inside a fixed box — tail-aligned so the caret end stays visible — and fades the leading edge into the field's own fill rather than cutting mid-glyph. The search bar now renders its prefix, query and match count as separate pieces so the query can be clipped to the space between them.

Adopted by the search bars, recent-folder entries, reader and story document text, and diff comments. Static labels (buttons, badges, "Reader Mode") keep the cheap single-render path.

Baseline follow-up (0ff51f0) — the first cut scaled the emoji surface to the text line height, which left the glyph sitting a pixel high. That surface is the emoji font's entire line box (160×210: a 160×160 glyph over 50 px of empty descent padding, baseline at the 160 px ascent), so fitting the box to the 17 px line put the emoji baseline at 13 against the text's 14. It now scales by the ascent ratio, which makes the glyph exactly the text ascent tall and puts both baselines on the same line; the empty descent padding that then hangs past the line box is clipped by the blit. The pixel math rounds rather than truncates — at these sizes one pixel of bias is exactly what the eye catches.

Verification

Both bugs are covered by tests confirmed to fail before the fix and pass after — the backspace test reports expected "dev/github/" found "dev/github/architec" and the expand-animation test fails outright on the old gates. Same for the interrupted-collapse geometry (expected 220, found 400).

zig build clean · zig build test 325 + 3 passed · zig fmt --check clean · just lint "No issues found".

TextInput is covered by unit tests (blink cycle, ⌘A replace-on-type, Backspace scopes with and without a selection, modifier gating so plain letters and ⌃/⌥ combos fall through, max_len and the character filter) and the recent-folders overlay has component-level tests driving real SDL events through handleEvent.

text_render is covered by unit tests for run splitting (ZWJ sequences, keycaps, skin tones, emoji at both ends) and emoji scaling, baseline placement against the real measured metrics, plus a test that opens the real Apple Color Emoji font and asserts a composed line comes back at the text line height instead of 160 px. That test was confirmed to fail when the scaling is removed (expected 17, found 160).

Not covered by automated tests: clipboard round-trips (⌘C/⌘V call into SDL, which needs a window), caret / selection / fade rendering, and the reader/story/diff adoptions of the shared field — those are verified by build and inspection only.

Test plan

  • ⌘O, then immediately start typing — the first character lands in the search field, not in the shell behind it.
  • In that search field: ⌥Backspace removes one path segment at a time, ⌘Backspace clears it, plain Backspace removes one character.
  • Press ⌘O twice in quick succession (inside the 200 ms expand) — the panel reverses smoothly from its current size instead of jumping to full width first.
  • ⌘T, then immediately press a digit — the worktree shortcut is handled by the overlay rather than reaching the terminal.
  • ⌥Backspace in the worktree name field, the ⌘F search in reader (⌘R) and story overlays, and a ⌘D diff comment — all delete a word; ⌘Backspace clears each field.
  • A caret blinks in the recent-folders search, and in the reader (⌘R → ⌘F) and story search bars.
  • In each field: ⌘A highlights the text, then typing replaces it, ⌘C copies it, ⌘V pastes. In the diff comment editor the ⌘A highlight should follow the wrapped lines.
  • Copy an emoji elsewhere, then ⌘V it into a diff comment and press Backspace once — the whole character disappears, not a fragment. (This is now testable; it was not before, which is why it was left unchecked on the first round.)
  • Copy a multi-line block of text and ⌘V it into the recent-folders search — it arrives as a single line with the newlines dropped.
  • ⌘V a string with spaces and slashes into the worktree name field — only the characters the field accepts when typed should appear.
  • With [logging].min_level = "debug", toggle grid/full on a live codex session and confirm ~/Library/Logs/Architect/architect.log shows the hold start → re-arm → release lines with a sensible reason.

Issue: There was no logging anywhere in the resize-settle hold, so there
was no way to tell from the logs why a terminal entered the sweeping
animation after a dimension change, how long it held, or why it released.
Solution: Emit debug lines when the hold starts (with old and new cell
dimensions), when a repaint wave re-arms it mid- or post-sweep, and when
it releases. The release line carries a classified reason derived from the
exit branches of resizeSettleHoldActive, so quiet settling, hitting the
hard cap while still streaming, and a foreground process that never
reacted to the SIGWINCH are distinguishable in the log.
Issue: In the recent-folders overlay Alt+Backspace and Cmd+Backspace only
deleted a single character instead of a word or the whole query, and the
first key pressed after Cmd+O was lost because the overlay did not accept
input until its expand animation finished.
Solution: The backspace handler ignored modifiers entirely, so all three
variants took the same path; both input gates tested state == .Open, and
during the ~200 ms .Expanding window the event fell through UiRoot into
the focused terminal. Backspace semantics now live in ui/text_edit.zig
(Cmd clears, Alt deletes a word, plain deletes one UTF-8 codepoint) and
every text input in the app uses it, replacing four divergent
implementations. The open-or-opening test moves onto ExpandingOverlay.State
as isOpenOrOpening() and is applied to every overlay that gated on .Open,
so keys are no longer dropped into the terminal during an expand;
startCollapsing now reverses from the overlay's current size so an
interrupted expand does not snap to full size first.
Issue: New tests added for the recent-folders fixes passed without ever
running. Zig only collects tests from files it actually analyzes, and
src/main.zig keeps an explicit registry for that reason, but three files
declaring tests were missing from it, including gfx/shimmer.zig, whose
tests had never run.
Solution: A deliberately failing canary test reported as green, and the
test-name table in the compiled binary confirmed the omission. Registering
the missing files takes the suite from 283 to 302 tests. Because
transitive reachability depends on which decls Sema happens to analyze,
the rule is now that every file with tests is listed explicitly, enforced
by scripts/check-test-registry.sh from just lint, which CI already runs.
@forketyfork
forketyfork requested a lite review from Copilot August 5, 2026 06:27
@forketyfork
forketyfork marked this pull request as ready for review August 5, 2026 06:27

Copilot AI 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.

Pull request overview

This PR fixes input handling bugs in UI overlays (Backspace modifier semantics and keyboard capture during the opening animation), adds diagnostics for the resize-settle lifecycle, and closes a Zig test-discovery gap by explicitly registering test-bearing files and enforcing that via lint.

Changes:

  • Introduces src/ui/text_edit.zig as the shared, tested implementation of macOS-style Backspace behavior (plain/⌥/⌘) and wires multiple overlays to it.
  • Standardizes “overlay owns keyboard while expanding” by adding ExpandingOverlay.State.isOpenOrOpening() and updating event gates/toggles accordingly (including smoother mid-expand collapse).
  • Adds resize-settle debug logging + release-reason classification, and enforces explicit Zig test registration via scripts/check-test-registry.sh + just lint.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/ui/text_edit.zig New shared helper for consistent Backspace semantics (UTF-8 safe) with tests.
src/ui/components/worktree_overlay.zig Uses shared backspace logic; fixes overlay key gating during open/expanding.
src/ui/components/story_overlay.zig Delegates backspace handling to shared helper for search field.
src/ui/components/recent_folders_overlay.zig Fixes modifier-aware backspace + keyboard capture during expand; adds tests.
src/ui/components/reader_overlay.zig Delegates backspace handling to shared helper for search field.
src/ui/components/pill_group.zig Uses isOpenOrOpening() to collapse other overlays consistently.
src/ui/components/help_overlay.zig Uses isOpenOrOpening() for toggle gating.
src/ui/components/expanding_overlay.zig Adds isOpenOrOpening() and fixes interrupted collapse geometry; adds tests.
src/ui/components/diff_overlay.zig Delegates backspace handling to shared helper for comment editor.
src/session/state.zig Adds resize-settle release-reason classifier + debug logs; adds tests.
src/main.zig Registers additional test-bearing files to ensure tests actually run.
src/app/layout.zig Adds debug log for resize-settle hold start with old/new dimensions.
scripts/check-test-registry.sh New lint check to enforce explicit test registration (see comments).
README.md Documents immediate keyboard capture and consistent Backspace semantics.
justfile Runs test-registry check during just lint.
docs/perf-debugging.md Documents new resize-settle debug tracing and meanings.
docs/development.md Documents Zig test discovery/registration requirement.
docs/ARCHITECTURE.md Documents the new ui/text_edit.zig module and overlay gating convention.
CLAUDE.md Updates contributor guidance to match new conventions/checks.
Suppressed comments (1)

scripts/check-test-registry.sh:37

  • Same portability issue as above: this summary line also uses GNU grep’s --include, which will break on BSD grep. Reuse the same find ... -exec grep ... pipeline here as well.
echo "test registry: all $(grep -rl '^test ' src --include='*.zig' | wc -l | tr -d ' ') files with tests are reachable"

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread scripts/check-test-registry.sh
Issue: The search fields had no visible caret, Cmd+A did nothing, and
Cmd+C/Cmd+V were not handled anywhere, so text could not be copied into or
out of any field — including pasting characters that cannot be typed.
Solution: text_edit grows a TextInput that owns the buffer plus the state a
focused field needs (caret blink phase, select-all flag) and handles the
macOS editing keys: Backspace, Cmd+A, Cmd+C, Cmd+V. All five fields store
their text in one, so behavior can no longer drift between them. The model
is append-only: the caret sits at the end and the only selection is
everything, which is what these single-purpose fields need and keeps the
per-field rendering to a caret plus one highlight band. Fields are
configured with a separator set, a length cap and a character filter, so
pasting multi-line text into a one-line box drops the newlines and pasting
into the worktree name field drops characters it would reject when typed.
Reader and story overlays now request frames while their search is open,
without which the caret cannot blink under idle throttling.
@forketyfork
forketyfork marked this pull request as draft August 5, 2026 07:11
@forketyfork
forketyfork marked this pull request as ready for review August 5, 2026 09:55
@forketyfork
forketyfork requested a lite review from Copilot August 5, 2026 09:56

Copilot AI 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.

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.

Issue: A long pasted line in the search bar rendered past the bar's edges,
and emoji pasted into a field or shown in reader/story mode were drawn
enormously larger than the surrounding text.
Solution: Both come from rendering a text texture at whatever size SDL_ttf
returns. Apple Color Emoji is a non-scalable bitmap font with a single
160 px strike, measured here: at 14 pt it reports height 210, "hello"
renders 30x17 but "hi <emoji>" renders 174x160, and TTF_SetFontSize
reports success while changing nothing. The terminal already avoids this
by scaling every glyph into its cell; UI text had no equivalent. The new
text_render module does the same job for a line: it splits the string into
emoji and text runs, renders each with the font that owns it, scales the
emoji runs to the text line height and composes them into one surface. Its
drawClippedTail draws a line inside a fixed box, tail-aligned so the caret
end stays visible, and fades the leading edge into the field's own fill
instead of cutting mid-glyph. The search bar now renders its prefix, query
and match count separately so the query can be clipped to the space
between them.
@forketyfork
forketyfork marked this pull request as draft August 5, 2026 12:10
Issue: Emoji rendered in UI text sat slightly too high in the line.
Solution: The surface SDL_ttf returns for an emoji run is the emoji font's
entire line box — 160x210 for Apple Color Emoji, a 160x160 glyph over 50 px
of empty descent padding, with its baseline at the 160 px ascent. Fitting
that whole box into the text line height put the emoji baseline at 13 where
the text's is at 14, lifting the glyph. Scaling by the ascent ratio instead
makes the glyph exactly the text ascent tall and puts both baselines on the
same line; the empty descent padding that then extends past the line box is
clipped harmlessly by the blit. The pixel math rounds rather than truncates,
since at these sizes one pixel of bias is what the eye picks up.
Issue: A file named '1' holding 'zig build run' output was sitting untracked
in the repo root and got swept into the previous commit by 'git add -A'.
Solution: Untrack it; the file itself is left alone on disk.
@forketyfork
forketyfork marked this pull request as ready for review August 5, 2026 12:55
@forketyfork
forketyfork merged commit b42d482 into main Aug 5, 2026
4 checks passed
@forketyfork
forketyfork deleted the fix/recent-folders-input branch August 5, 2026 13:00
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