Skip to content

iOS: restore hold-to-repeat Backspace on the terminal keyboard - #6288

Closed
lawrencecchen wants to merge 2 commits into
mainfrom
fix-ios-backspace-repeat
Closed

lawrencecchen wants to merge 2 commits into
mainfrom
fix-ios-backspace-repeat

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 17, 2026 •

Copy link
Copy Markdown
Contributor

Holding Backspace on the iOS terminal keyboard deleted one character and then stopped, instead of repeat-deleting continuously while held.

Root cause

TerminalInputTextView is a UITextView that keeps a perpetually-empty local document: every committed keystroke is forwarded to the Mac, then the local buffer is cleared. The software keyboard's auto-repeat timer for Backspace only keeps firing deleteBackward() while the first responder reports hasText == true. With the inherited empty-document hasText reading false, the repeat died after the first delete.

The working hold-to-repeat mechanism was developed in the unmerged native-input PRs (#5573, #5596, documentless variant in commit 2577f2312) but never landed on main. So main still has the plain UITextView with no hasText override and Backspace routing that gates on hasText. This is the regression.

Fix

Applies the minimal, principled piece of that prior work that fits a UITextView (iSH's technique):

  • Override hasText -> true so the keyboard keeps auto-repeating Backspace ("it's always ok to send a delete").
  • Re-key deleteBackward() off markedTextRange (IME composition in progress) instead of hasText. Since hasText is now a forced constant, the armed-modifier guards drop their !hasText condition and the local-edit branch routes on markedTextRange != nil only. While composing, the delete edits the marked text locally; otherwise it is a real backspace forwarded to the Mac on every repeat tick.

Principled, not a hack: it restores the documented mechanism the prior fix used and removes the now-meaningless hasText byte-routing rather than papering over it. The IME composition path (Japanese/CJK) is preserved by keying local edits off markedTextRange.

Tests

Two-commit red/green structure in ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalInputBackspaceRepeatTests.swift:

  • Commit 1 adds the failing test (asserts hasText == true, the keyboard's repeat gate). Red on main because the inherited empty-document hasText is false.
  • Commit 2 adds the fix. Green.

Residual risk

Low. The change is confined to one view. The armed-modifier Backspace ladders (⌘/⌥/⌃/⇧ + Backspace) are exercised by the existing TerminalInputAccessoryShiftTests; those guards now key off markedTextRange only, which is the same condition they already required (they previously also required !hasText, which was always true for the empty document, so behavior is unchanged for the non-composing case). Dictation/autocorrect committed blocks are unaffected (they ride insertText/textChange, not deleteBackward).

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Low Risk
Single-view input routing change; non-composing behavior matches the prior empty-document case, with IME guarded via marked text only.

Overview
Holding Backspace on the iOS terminal software keyboard only deleted once because UIKit stops auto-repeating deleteBackward() when hasText is false, and the proxy’s local UITextView stays empty after each keystroke is forwarded to the Mac.

TerminalInputTextView now overrides hasText to always return true (same approach as iSH) so the keyboard keeps firing repeat deletes. deleteBackward() no longer uses hasText for routing: accessory-modifier branches drop their !hasText checks, and local vs Mac backspace is decided only by markedTextRange (IME composition still edits marked text locally).

New TerminalInputBackspaceRepeatTests assert hasText is always true and that five successive deleteBackward() calls each invoke onBackspace when not composing.

Reviewed by Cursor Bugbot for commit 318abaa. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Restores hold-to-repeat Backspace on the iOS terminal keyboard so holding the key continuously deletes characters as expected. Forces the input view to always report hasText == true and updates delete handling to respect IME composition.

  • Bug Fixes
    • Always return true for hasText to keep the keyboard’s Backspace auto-repeat active.
    • Route deleteBackward by markedTextRange to preserve IME; remove hasText checks and forward real backspace on each repeat when not composing.
    • Add tests to assert hasText == true and that repeated deletes reach the terminal.

Written for commit 318abaa. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Fixed Backspace key hold-to-repeat functionality in the iOS terminal input view.
    • Enhanced input method editor (IME) composition state handling for modifier key operations.
  • Tests

    • Added test suite to validate sustained Backspace repeat behavior on empty input during keyboard hold.

lawrencecchen and others added 2 commits June 16, 2026 19:41
The iOS terminal input view (TerminalInputTextView) is a UITextView with a
perpetually-empty document. The software keyboard only keeps auto-repeating
deleteBackward() while the first responder reports hasText == true; the
inherited empty-document hasText reads false, so holding Backspace deletes
exactly one character and then stops.

This test asserts hasText is a constant true (the keyboard's repeat gate) and
that each repeat tick reaches the Mac. It is red on current main: the inherited
UITextView.hasText returns false for the empty document.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Holding Backspace on the iOS terminal keyboard deleted one character and then
stopped instead of repeat-deleting while held. TerminalInputTextView is a
UITextView that keeps a perpetually-empty local document (every keystroke is
forwarded to the Mac, then the buffer is cleared). The software keyboard's
auto-repeat timer only keeps firing deleteBackward() while the first responder
reports hasText == true, and the inherited empty-document hasText read false, so
the repeat died after the first delete.

The working fix was developed in the unmerged native-input PRs (#5573 / #5596,
documentless variant in commit 2577f23) but never landed on main, so main has
the plain UITextView with no hasText override. This applies the minimal,
principled piece of that work that fits a UITextView (iSH's technique):

- Override hasText -> true so the keyboard keeps auto-repeating Backspace
  ("it's always ok to send a delete").
- Re-key deleteBackward() off markedTextRange (IME composition in progress)
  instead of hasText. hasText is now a forced constant, so the armed-modifier
  guards drop their !hasText condition and the local-edit branch routes on
  markedTextRange != nil only. While composing, the delete edits the marked text
  locally; otherwise it is a real backspace forwarded to the Mac on every repeat
  tick.

Makes the red test from the prior commit pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 17, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 17, 2026 2:48am
cmux-staging Building Building Preview, Comment Jun 17, 2026 2:48am

@coderabbitai

coderabbitai Bot commented Jun 17, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4b1d3457-8147-48be-8d51-88bad0ad2f60

📥 Commits

Reviewing files that changed from the base of the PR and between e43a074 and 318abaa.

📒 Files selected for processing (2)
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
  • ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalInputBackspaceRepeatTests.swift

📝 Walkthrough

Walkthrough

TerminalInputTextView overrides hasText to hard-code true, ensuring iOS auto-repeats Backspace on hold even when the document is empty. deleteBackward() is updated to remove all !hasText guards and instead gate modifier-routing (Cmd+Backspace, Ctrl+Backspace) and the local-edit branch on markedTextRange == nil. A new test suite validates both contract points.

Changes

Hold-to-repeat Backspace contract and implementation

Layer / File(s) Summary
hasText override and deleteBackward() routing rewrite
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
Adds override var hasText: Bool { true } with documentation, removes all !hasText guards from deleteBackward(), and replaces them with markedTextRange == nil checks for Cmd+Backspace, Ctrl/Shift+Backspace, and the local-edit fallback.
Hold-to-repeat Backspace tests
ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalInputBackspaceRepeatTests.swift
Adds iOS-only TerminalInputBackspaceRepeatTests asserting hasText is always true and that repeated deleteBackward() calls on an empty document each invoke onBackspace.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐇 Hold that key and feel the beat,
hasText: true — a tidy feat!
No more swallowed taps in a row,
Each backspace fires, watch them go.
The rabbit hops with every tick. ✨

🚥 Pre-merge checks | ✅ 20 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (20 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and accurately summarizes the main change: restoring Backspace hold-to-repeat functionality on the iOS terminal keyboard.
Description check ✅ Passed The description provides a thorough explanation of the problem, root cause, fix, and testing approach. However, it is missing the required Demo Video section and incomplete Checklist items.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed Production changes are confined to TerminalInputTextView (a UITextView subclass, implicitly MainActor in Swift 6) and only add a constant computed property override and method logic; no shared muta...
Cmux Swift Blocking Runtime ✅ Passed No blocking or timing-based synchronization (sleep, semaphores, Task.sleep, locks, asyncAfter, polling, main.sync) introduced in production or test Swift code. Changes are purely structural input h...
Cmux Expensive Synchronous Load ✅ Passed PR introduces only simple keyboard input handler modifications to TerminalInputTextView with no expensive synchronous loaders (RestorableAgentSessionIndex.load(), sysctl, FileManager, etc.) added t...
Cmux Cache Substitution Correctness ✅ Passed PR has no cache substitution operations. Changes are confined to iOS UI input handling (hasText property override, deleteBackward routing via markedTextRange), with no persistence, history, undo, o...
Cmux No Hacky Sleeps ✅ Passed PR modifies only Swift files (.swift). Rule explicitly excludes Swift code, which is covered by separate 'swift-blocking-runtime.md' check. Check only applies to TypeScript, JavaScript, shell, or b...
Cmux Algorithmic Complexity ✅ Passed Production Swift changes are O(1) constant-time operations (property override returning true, guard-statement sequence). Test code with 5-iteration fixed loop is properly scoped. No collection scan...
Cmux Swift Concurrency ✅ Passed No legacy async patterns (DispatchQueue, Combine, completion handlers, fire-and-forget Tasks) introduced or expanded in the Swift changes—only synchronous Backspace repeat logic and test code using...
Cmux Swift @Concurrent ✅ Passed No async functions, @concurrent annotations, or nonisolated async work present in changes. All code is synchronous UI/property logic appropriately isolated to MainActor via UITextView inheritance.
Cmux Swift File And Package Boundaries ✅ Passed PR adds only 26 net lines to a 1252-line file (2.5% growth) to fix a focused bug in iOS Backspace hold-to-repeat, well below the 250-line failure threshold. No new responsibilities are added—the ch...
Cmux Swift Logging ✅ Passed No Swift logging violations found. Production code adds only comments and logic changes (hasText override, deleteBackward modification) with zero print/NSLog/debugPrint/dump statements. Test file c...
Cmux User-Facing Error Privacy ✅ Passed No user-facing error messages, alerts, or sensitive information exposed. Changes consist of developer-only comments, documentation, and test code per the rules' allowed exceptions.
Cmux Full Internationalization ✅ Passed The PR changes are confined to iOS terminal input implementation (TerminalInputTextView.swift) and tests. No user-facing Swift text, string catalogs, web UI, or localization files were added or mod...
Cmux Swiftui State Layout ✅ Passed PR contains no SwiftUI changes—only UIKit UITextView modifications and Swift Testing tests. The SwiftUI state layout check is not applicable.
Cmux Architecture Rethink ✅ Passed PR makes small, localized fix without anti-patterns: overrides hasText to constant true, re-keys deleteBackward to markedTextRange for IME composition. No timing repairs, mutable flags, lifecycle s...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR modifies only iOS UITextView subclass (TerminalInputTextView) and its tests. Contains no NSWindow, NSPanel, NSWindowController, SwiftUI Window, or WindowGroup declarations—the frameworks/constru...
Cmux Source Artifacts ✅ Passed Both changed files are hand-written product source and test code intentionally part of the iOS terminal feature, matching the rule's pass criteria for intentional source/test code.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-ios-backspace-repeat

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes hold-to-repeat Backspace on the iOS terminal keyboard by overriding hasText → true (iSH's documented technique) so the keyboard's auto-repeat timer keeps firing deleteBackward() while Backspace is held, and removes the now-meaningless !hasText guards throughout deleteBackward() in favor of markedTextRange-only routing.

  • override var hasText: Bool { true } is added with thorough doc-comment explaining the invariant and its constraint on internal routing; all four modifier-armed guards and the final super.deleteBackward() branch are updated to key off markedTextRange only, which was the effective condition anyway since the document was always empty.
  • A new TerminalInputBackspaceRepeatTests suite locks the contract with a hasText == true invariant test and a simulated 5-tick hold-repeat test that verifies every tick reaches onBackspace (the Mac-forwarding path), not just the first.

Confidence Score: 4/5

Safe to merge; the change is confined to one view, the logic inside deleteBackward() is semantically identical to the pre-fix code for every execution path, and the new tests lock the repeat contract.

The only behavioral change outside the fix itself is that UIKit's hasText contract is intentionally violated — the document is always empty but hasText always returns true. That could enable Select All and similar editing-menu items via canPerformAction on an always-empty document. Given the view's invisible text/cursor and the terminal surface's gesture handling, this is unlikely to be user-visible, but it hasn't been explicitly guarded against.

Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift — the hasText override at line 72 and its interaction with UIKit's canPerformAction editing-menu path.

Important Files Changed

Filename Overview
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift Overrides hasText to always return true and removes the now-redundant !hasText guards from deleteBackward(); logic paths are semantically identical to pre-fix behavior but UIKit's editing-menu contract is deliberately violated.
ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalInputBackspaceRepeatTests.swift New test suite with two tests: one pins the hasText == true invariant (the keyboard's repeat gate) and one verifies that five successive deleteBackward() calls on an empty document all route to onBackspace, proving the hold-to-repeat path reaches the Mac on every tick.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant KB as iOS Keyboard Timer
    participant TITV as TerminalInputTextView
    participant Mac as Mac Terminal

    Note over KB: User holds Backspace key
    KB->>TITV: hasText? → true (allows repeat)
    KB->>TITV: deleteBackward() [tick 1]
    alt "Modifier armed (e.g. ⌘) and markedTextRange == nil"
        TITV->>Mac: onEscapeSequence (e.g. Ctrl+U)
    else "IME composing (markedTextRange != nil)"
        TITV->>TITV: super.deleteBackward()
    else Normal (no modifier, not composing)
        TITV->>Mac: onBackspace()
    end
    KB->>TITV: hasText? → true (keyboard keeps repeating)
    KB->>TITV: deleteBackward() [tick 2…N]
    TITV->>Mac: onBackspace() (every tick)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant KB as iOS Keyboard Timer
    participant TITV as TerminalInputTextView
    participant Mac as Mac Terminal

    Note over KB: User holds Backspace key
    KB->>TITV: hasText? → true (allows repeat)
    KB->>TITV: deleteBackward() [tick 1]
    alt "Modifier armed (e.g. ⌘) and markedTextRange == nil"
        TITV->>Mac: onEscapeSequence (e.g. Ctrl+U)
    else "IME composing (markedTextRange != nil)"
        TITV->>TITV: super.deleteBackward()
    else Normal (no modifier, not composing)
        TITV->>Mac: onBackspace()
    end
    KB->>TITV: hasText? → true (keyboard keeps repeating)
    KB->>TITV: deleteBackward() [tick 2…N]
    TITV->>Mac: onBackspace() (every tick)
Loading

Reviews (1): Last reviewed commit: "iOS terminal input: restore hold-to-repe..." | Re-trigger Greptile

/// Because `hasText` is now a forced constant, internal byte-routing must NOT
/// key off it; ``deleteBackward()`` and its modifier guards key off
/// ``markedTextRange`` (IME composition in progress) instead.
override var hasText: Bool { true }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 hasText == true enables UIKit editing-menu items on an always-empty document

UITextView.canPerformAction(_:withSender:) uses hasText to gate "Select All" (and in some UIKit versions "Cut"/"Copy") in the long-press editing menu. With hasText forced to true, a user long-pressing on the terminal surface could see "Select All" and related menu items at all times, even though the document is always empty and any selection would be over zero characters.

In practice the risk is low because the terminal surface captures long-press gestures before the UITextView magnifier fires, textColor and tintColor are both .clear, and autocorrectionType/spellCheckingType are off. Worth verifying that a long-press on the soft-keyboard input area doesn't accidentally surface the editing menu, since UIKit's hasText contract is now intentionally violated here.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

lawrencecchen added a commit that referenced this pull request Jun 20, 2026
…hor (#6299)

* ios: sustain hold-to-repeat Backspace via a virtual delete-repeat anchor

Hold-to-repeat Backspace on the iOS soft keyboard never repeated. Device
debugging confirmed the cause: with an EMPTY virtual document, UIKit no-ops
the keyboard's software delete and never calls deleteBackward() while the key
is held, so backspace did not reach the Mac. Forcing hasText == true alone
(the earlier #6288 attempt) does not change this.

Fix: convert TerminalInputTextView from a UITextView subclass to a bare
UIView conforming to UIKeyInput + UITextInput that exposes a one-character
virtual document. When not composing it shows a hidden zero-width
"delete-repeat anchor" (toggling \u{200B}/\u{2060}); each empty-buffer
deleteBackward() forwards a real backspace via onBackspace and brackets the
anchor toggle in inputDelegate.textWillChange/textDidChange so UIKit re-arms
its document-driven key-repeat timer. IME composition (markedText) suppresses
the anchor; a delete during composition cancels the composition instead of
forwarding a stray backspace.

Supersedes the hasText-only approach in
#6288 (can be closed in favor of
this); related to #6238.

Single commit (not red/green): the fix is a whole-view rewrite from
UITextView to UIView/UIKeyInput/UITextInput, so the failing test and the view
it tests are inseparable. The new TerminalInputBackspaceRepeatTests asserts
the observable invariants that sustain the repeat (non-empty 1-char document
when idle, N deletes => N backspaces with the document re-armed non-empty
after each, the textWillChange/textDidChange re-arm firing per delete, the
anchor char alternating, and composing suppressing both the anchor and any
stray backspace), so reverting to UITextView, dropping the anchor, or removing
the re-arm all fail it.

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

* Refresh Swift file-length budget for documentless input rewrite

TerminalInputTextView grew with the hand-rolled UIKeyInput/UITextInput
conformance (the proven delete-repeat fix). Accepting as known debt; splitting the
conformance into an extension file is a follow-up (file-org).

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

* ios: shorten IME composition on composing-delete instead of cancelling

While composing CJK/Japanese/Korean/pinyin text the documentless input
view cancelled the entire marked composition on the first Backspace and
emitted nothing, so mid-composition correction was impossible (en+ja are
supported locales). Restore the prior UITextView behavior: deleteBackward()
during composition now drops the last grapheme (Character, not a UTF-16
unit, so multi-scalar glyphs are never split) and re-presents the shortened
candidate via setMarkedText, which brackets the change in
textWillChange/textDidChange and derives markedTextRange/selectedTextRange
from the new string so UIKit and the IME stay in sync. Removing the last
unit clears/unmarks the composition. Still emits zero bytes to the Mac while
composing; the non-composing forward-DEL + anchor re-arm path is unchanged.

Update the regression test: a multi-char composition shortens by one per
delete (still composing, zero onBackspace), clears on the last unit, and a
subsequent non-composing delete forwards a backspace again. Adds a
grapheme-boundary case (flag emoji = one grapheme, multiple UTF-16 units).

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

* Refresh Swift file-length budget after IME composing-delete fix

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

* test: conform mock UITextInputDelegate to the iOS 18.4 SDK (conversationContext)

main's bump to the iOS 26 SDK made conversationContext(_:didChange:) a required
UITextInputDelegate method; the backspace-repeat test's mock delegate didn't
implement it, failing the cmuxFeatureTests build. Add the unused stub.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ShubhamPatilsd pushed a commit to emergent-inc/mosaic that referenced this pull request Jul 9, 2026
…hor (#6299)

* ios: sustain hold-to-repeat Backspace via a virtual delete-repeat anchor

Hold-to-repeat Backspace on the iOS soft keyboard never repeated. Device
debugging confirmed the cause: with an EMPTY virtual document, UIKit no-ops
the keyboard's software delete and never calls deleteBackward() while the key
is held, so backspace did not reach the Mac. Forcing hasText == true alone
(the earlier #6288 attempt) does not change this.

Fix: convert TerminalInputTextView from a UITextView subclass to a bare
UIView conforming to UIKeyInput + UITextInput that exposes a one-character
virtual document. When not composing it shows a hidden zero-width
"delete-repeat anchor" (toggling \u{200B}/\u{2060}); each empty-buffer
deleteBackward() forwards a real backspace via onBackspace and brackets the
anchor toggle in inputDelegate.textWillChange/textDidChange so UIKit re-arms
its document-driven key-repeat timer. IME composition (markedText) suppresses
the anchor; a delete during composition cancels the composition instead of
forwarding a stray backspace.

Supersedes the hasText-only approach in
manaflow-ai/cmux#6288 (can be closed in favor of
this); related to manaflow-ai/cmux#6238.

Single commit (not red/green): the fix is a whole-view rewrite from
UITextView to UIView/UIKeyInput/UITextInput, so the failing test and the view
it tests are inseparable. The new TerminalInputBackspaceRepeatTests asserts
the observable invariants that sustain the repeat (non-empty 1-char document
when idle, N deletes => N backspaces with the document re-armed non-empty
after each, the textWillChange/textDidChange re-arm firing per delete, the
anchor char alternating, and composing suppressing both the anchor and any
stray backspace), so reverting to UITextView, dropping the anchor, or removing
the re-arm all fail it.

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

* Refresh Swift file-length budget for documentless input rewrite

TerminalInputTextView grew with the hand-rolled UIKeyInput/UITextInput
conformance (the proven delete-repeat fix). Accepting as known debt; splitting the
conformance into an extension file is a follow-up (file-org).

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

* ios: shorten IME composition on composing-delete instead of cancelling

While composing CJK/Japanese/Korean/pinyin text the documentless input
view cancelled the entire marked composition on the first Backspace and
emitted nothing, so mid-composition correction was impossible (en+ja are
supported locales). Restore the prior UITextView behavior: deleteBackward()
during composition now drops the last grapheme (Character, not a UTF-16
unit, so multi-scalar glyphs are never split) and re-presents the shortened
candidate via setMarkedText, which brackets the change in
textWillChange/textDidChange and derives markedTextRange/selectedTextRange
from the new string so UIKit and the IME stay in sync. Removing the last
unit clears/unmarks the composition. Still emits zero bytes to the Mac while
composing; the non-composing forward-DEL + anchor re-arm path is unchanged.

Update the regression test: a multi-char composition shortens by one per
delete (still composing, zero onBackspace), clears on the last unit, and a
subsequent non-composing delete forwards a backspace again. Adds a
grapheme-boundary case (flag emoji = one grapheme, multiple UTF-16 units).

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

* Refresh Swift file-length budget after IME composing-delete fix

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

* test: conform mock UITextInputDelegate to the iOS 18.4 SDK (conversationContext)

main's bump to the iOS 26 SDK made conversationContext(_:didChange:) a required
UITextInputDelegate method; the backspace-repeat test's mock delegate didn't
implement it, failing the cmuxFeatureTests build. Add the unused stub.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jul 18, 2026
Mirror of PR #6288. Force hasText=true so the keyboard's auto-repeat timer keeps
firing deleteBackward while Backspace is held (empty UITextView reports hasText
false, stopping repeat after one delete). Re-key deleteBackward branches off
markedTextRange (IME composition) instead of hasText.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — 318abaa1 Deployed Jun 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants