Skip to content

Fix stray 'C' insertion from Speakly dictation - #2413

Merged
austinywang merged 8 commits into
mainfrom
issue-2400-speakly-voice-stray-c
Apr 1, 2026
Merged

austinywang merged 8 commits into
mainfrom
issue-2400-speakly-voice-stray-c

Conversation

@austinywang

@austinywang austinywang commented Mar 31, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Route committed IME and accessibility-inserted text through ghostty_surface_text instead of keyboard-event encoding.
  • Strip leading escape/control sequences from externally committed text before it reaches the PTY so Speakly dictation cannot leak a stray C prefix.
  • Add regression coverage for sanitized escape-prefixed text and unchanged literal bracket-prefixed text.

Testing

  • Not run locally per repo policy.
  • Added ExternalCommittedTextSanitizationTests in cmuxTests/CJKIMEInputTests.swift.

Demo Video

  • N/A

Review Trigger (Copy/Paste as PR comment)

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

Checklist

  • I tested the change locally
  • I added or updated tests for behavior changes
  • I updated docs/changelog if needed
  • I requested bot reviews after my latest commit (copy/paste block above or equivalent)
  • All code review bot comments are resolved
  • All human review comments are resolved

Closes #2400


Summary by cubic

Fixes the stray “C” from Speakly dictation by sanitizing escape‑prefixed committed text and sending IME/AX text as typed input so shells and editors behave correctly. Programmatic insertText keeps literal ESC bytes for automation.

  • Bug Fixes
    • Treat AX/dictation and single‑arg insertText: as external committed text and sanitize: strip leading ESC/CSI/SS3 and OSC/DCS/APC/PM sequences, including C1 CSI (U+009B); preserve newline/tab; drop if empty.
    • Deliver text with typed‑input semantics: buffer printable chars, send Return/Tab as key events; send Escape as a key for external text, but preserve ESC bytes for programmatic insertText.
    • Added tests covering C1 CSI sanitization, literal bracket‑prefixed text, preserving automation control bytes, direct insertText newline → Return and ESC preservation, and AX value sanitization.

Written for commit b71d6fb. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Improved IME and accessibility committed-text handling: inputs are validated as UTF‑8, leading escape/control/CSI-like sequences are stripped, empty results are dropped, buffered text is flushed as text events, and control characters (Return/Tab/Escape) are delivered as separate key events. Debug logging reports sanitization differences.
  • Tests

    • Added tests for CSI/escape stripping, literal-bracket cases, preservation of benign control bytes (newline/tab), and regression ensuring trailing newline is delivered as a Return key event rather than pasted text.

@vercel

vercel Bot commented Mar 31, 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 Apr 1, 2026 8:53am

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@coderabbitai

coderabbitai Bot commented Mar 31, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Sanitizes externally committed text in GhosttyNSView by stripping leading terminal escape/CSI/control bytes, forwards the sanitized UTF‑8 payload to the terminal surface, and splits control scalars into explicit control key events for in-app input routing. Adds unit and integration tests for these behaviors.

Changes

Cohort / File(s) Summary
Input handling & sanitization
Sources/GhosttyTerminalView.swift
Reworked sendTextToSurface(_:) to buffer/flush printable Unicode scalars and emit specific control key events (Return/Tab/Escape) instead of sending raw bytes; added flushBufferedText() and sendControlKey(_:); added sanitizeExternalCommittedText(_:) and helpers to strip leading ESC/C1 CSI/control sequences; updated insertText(_:replacementRange:) to sanitize when NSApp.currentEvent == nil, log debug byte counts, and drop empty sanitized results.
Tests: CJK/IME & accessibility
cmuxTests/CJKIMEInputTests.swift
Added ExternalCommittedTextSanitizationTests (unit tests validating stripping of ESC/CSI/C1 prefixes and edge cases) and AccessibilityInsertTextRegressionTests (integration-style test ensuring committed text vs trailing Return are routed correctly).

Sequence Diagram(s)

sequenceDiagram
    participant IME as "IME / Accessibility"
    participant App as "NSApp"
    participant View as "GhosttyNSView"
    participant Surface as "ghostty_surface"

    IME->>View: insertText(commitString)
    View->>App: check NSApp.currentEvent
    alt currentEvent == nil
        View->>View: sanitizeExternalCommittedText(bytes)
        alt sanitized non-empty
            View->>Surface: ghostty_surface_text(sanitized UTF-8)
            Note right of Surface: printable text delivered
        else sanitized empty
            View-->>IME: drop (no send)
        end
    else
        View->>View: iterate unicodeScalars
        loop per scalar
            alt control scalar (LF/CR/TAB/ESC)
                View->>Surface: ghostty_surface_key(control key event)
            else printable
                View->>View: buffer printable scalar
            end
        end
        View->>Surface: ghostty_surface_text(flush buffered UTF-8)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I nibble stray ESCs from the forest floor,
I hop off prefixes that sneak in through the door,
I stitch UTF‑8 bits into tidy lines so neat,
No extra C at the start—just text you can greet,
Hooray, a clean input trail and a crunchy carrot treat.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix stray C insertion from Speakly dictation' directly and clearly summarizes the main bug fix, matching the core change objective from PR #2400.
Linked Issues check ✅ Passed The code changes directly address issue #2400 by sanitizing escape-prefixed text in GhosttyNSView.insertText and routing IME/accessibility text through ghostty_surface_text, preventing Speakly's stray C insertion.
Out of Scope Changes check ✅ Passed All changes are scoped to handling external committed text sanitization and IME input routing; no unrelated modifications detected beyond the stated objectives.
Description check ✅ Passed The pull request description includes all required template sections with substantive content: Summary explains the changes, Testing documents the testing approach, and the Checklist reflects actual completion status.

✏️ 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 issue-2400-speakly-voice-stray-c

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.

@cubic-dev-ai cubic-dev-ai 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.

No issues found across 2 files

@greptile-apps

greptile-apps Bot commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a bug where Speakly (and similar accessibility/dictation tools) injected a stray C character into the terminal by prepending an ESC[C cursor-forward CSI sequence before their committed text. Two changes address this:

  1. sendTextToSurface routing — switched from ghostty_surface_key (keyboard-event encoding) to ghostty_surface_text (paste-like path), matching upstream Ghostty behavior and the existing writeTextData / paste paths already in the codebase.
  2. sanitizeExternalCommittedText — a new static sanitizer that strips leading ANSI/escape sequences (ESC-based CSI, SS3, OSC/DCS/PM/APC, bare control characters) before any externally-committed text (NSApp.currentEvent == nil) reaches the PTY.

Key observations:

  • The ghostty_surface_text call pattern is consistent with the existing writeTextData helper used by the paste path (lines 4355–4360).
  • The NSApp.currentEvent == nil heuristic correctly distinguishes external AX/dictation calls from normal keyboard-driven insertText calls.
  • The sanitizer loop is designed to strip only leading sequences, stopping at the first printable byte.
  • Two P2 edge cases: the 0x9B C1-CSI branch is unreachable for valid Swift String UTF-8 input, and multi-byte nF sequences (ESC + 0x20–0x2F) are only partially consumed.
  • The regression tests were added in the same commit as the fix, skipping the repo's two-commit failing-test-first policy.

Confidence Score: 5/5

Safe to merge; all identified issues are edge-case or process concerns, not runtime regressions.

The fix is correct and targeted: it matches existing paste-path patterns, the sanitizer handles the specific Speakly ESC[C case cleanly, and three behavioral tests are included. The two logic comments (dead 0x9B branch and partial nF consumption) are real observations but neither causes incorrect behavior for any realistic dictation input. The commit-structure note is a process gap, not a code defect.

Sources/GhosttyTerminalView.swift — specifically the consumeLeadingEscapeSequence default branch and the 0x9B C1-CSI path.

Important Files Changed

Filename Overview
Sources/GhosttyTerminalView.swift Core fix: switches IME committed-text path from ghostty_surface_key to ghostty_surface_text (consistent with existing paste path), and adds a sanitizer that strips leading ANSI/escape sequences from externally-inserted (AX/dictation) text. Two minor edge-case issues: 0x9B C1-CSI branch is dead code for Swift String input, and nF multi-byte sequences (ESC + 0x20–0x2F) are only partially consumed.
cmuxTests/CJKIMEInputTests.swift Adds ExternalCommittedTextSanitizationTests covering the ESC[C strip, multiple chained sequences, and passthrough of literal bracket text. Tests exercise real runtime behavior through the static sanitize function. However, the test and fix were committed together, violating the repo's two-commit regression policy.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[insertText called] --> B{keyTextAccumulator != nil?}
    B -- Yes --> C[Accumulate into keyDown buffer]
    B -- No --> D{NSApp.currentEvent == nil?}
    D -- No\nKeyboard event --> E[chars unchanged]
    D -- Yes\nExternal / AX / Speakly --> F[sanitizeExternalCommittedText]
    F --> G{Leading ESC / ctrl bytes?}
    G -- 0x1B ESC --> H[consumeLeadingEscapeSequence\nCSI / SS3 / OSC / single-char]
    G -- 0x9B C1 CSI\ndead code for UTF-8 --> I[consumeLeadingCSISequence]
    G -- byte < 0x20 or 0x7F --> J[strip bare control char]
    G -- printable --> K[break — keep remaining text]
    H --> G
    I --> G
    J --> G
    K --> L[sanitizedChars]
    E --> L
    L --> M{sanitizedChars.isEmpty?}
    M -- Yes --> N[return early — nothing to send]
    M -- No --> O[sendTextToSurface\nghostty_surface_text — paste path]
Loading

Reviews (1): Last reviewed commit: "Fix Speakly escape leak on text insertio..." | Re-trigger Greptile

Comment thread Sources/GhosttyTerminalView.swift Outdated
Comment on lines +10111 to +10113
if byte == 0x9B {
index = consumeLeadingCSISequence(in: bytes, from: index + 1)
continue

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 C1 CSI (0x9B) check is unreachable for Swift String input

Swift's String stores characters in UTF-8. The Unicode character U+009B (C1 CSI) encodes as the two-byte sequence 0xC2 0x9B. When iterating over text.utf8, byte index 0 will be 0xC2, which is ≥ 0x20 and not 0x1B, so the outer loop's break fires immediately before this branch is ever reached.

The 0x9B check would only trigger for ISO-8859-1 / Latin-1 raw bytes — not for valid Swift String input.

This is dead code on the committed-text path (no real-world dictation tool produces a bare 0x9B via the Cocoa text-input pipeline), but it could create a false sense of coverage. A quick comment documenting this limitation would help future readers, or the branch could be removed.

Comment on lines +10146 to +10152
case 0x50, 0x5D, 0x5E, 0x5F:
// DCS/OSC/PM/APC: consume until BEL/ST or EOF.
return consumeLeadingEscapedStringSequence(in: bytes, from: next + 1)
default:
// Single-character escape.
return min(bytes.count, next + 1)
}

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 nF multi-byte escape sequences (ESC + 0x20–0x2F) are only partially consumed

The default branch handles every byte after ESC that isn't covered by the explicit case labels, including the 0x20–0x2F range. Those bytes are ECMA-48 "intermediate" bytes for nF sequences that require one or more additional intermediate bytes followed by a final byte — e.g. ESC SP F (3 bytes) for C0 charset designation.

Because default only consumes one byte past the ESC (return min(bytes.count, next + 1)), the remaining intermediate/final bytes of any nF sequence are left in the buffer. The outer sanitizeExternalCommittedText loop then encounters the final byte (e.g. F = 0x46, which is ≥ 0x20), hits the break, and includes that byte in the returned string — producing stray output.

In practice dictation tools do not inject nF sequences, so this shouldn't affect the Speakly fix. Worth a comment or a narrow guard to prevent silent partial strips if the sanitizer is extended later:

case 0x20...0x2F:
    // nF sequences: intermediate + final. Consume through the final byte (0x20–0x7E).
    var i = next + 1
    while i < bytes.count, (0x20...0x2F).contains(bytes[i]) { i += 1 }
    return i < bytes.count ? i + 1 : i

Comment on lines +875 to +896
final class ExternalCommittedTextSanitizationTests: XCTestCase {
func testStripsLeadingCSISequenceFromExternalCommittedText() {
XCTAssertEqual(
GhosttyNSView.sanitizeExternalCommittedText("\u{1B}[Chello"),
"hello"
)
}

func testStripsMultipleLeadingControlAndEscapeSequences() {
XCTAssertEqual(
GhosttyNSView.sanitizeExternalCommittedText("\u{1B}[1;5C\u{1B}OChello"),
"hello"
)
}

func testLeavesLiteralBracketPrefixedTextUntouched() {
XCTAssertEqual(
GhosttyNSView.sanitizeExternalCommittedText("[Code] review"),
"[Code] review"
)
}
}

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 Regression test commit policy: test and fix bundled in one commit

Per CLAUDE.md ("Regression test commit policy"), regression tests for a bug fix must be split into two commits so CI can prove the test fails without the fix:

  1. Commit 1 — failing test only (CI goes red)
  2. Commit 2 — the fix (CI goes green)

Both the new ExternalCommittedTextSanitizationTests and the sanitizer implementation landed in the same commit (e63d3ebc). The CI evidence that these tests catch the bug without the fix is therefore missing from the PR's commit history.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/GhosttyTerminalView.swift (1)

10068-10085: ⚠️ Potential issue | 🟠 Major

Route this through TerminalSurface.sendText(_:) instead of writing raw bytes here.

Line 10069 still drops eventless committed text whenever the runtime surface is temporarily nil. TerminalSurface.sendText(_:) already uses ghostty_surface_text and queues bytes until the surface comes back, so reusing it here avoids silent dictation/accessibility text loss during reparent/startup windows.

💡 Suggested change
 fileprivate func sendTextToSurface(_ chars: String) {
-        guard let surface = surface else { return }
-        guard let data = chars.data(using: .utf8), !data.isEmpty else { return }
+        guard !chars.isEmpty else { return }
 `#if` DEBUG
         let typingTimingStart = CmuxTypingTiming.start()
 `#endif`
 `#if` DEBUG
         cmuxWriteChildExitProbe(
@@
             ],
             increments: ["probeInsertTextCount": 1]
         )
 `#endif`
-        data.withUnsafeBytes { rawBuffer in
-            guard let baseAddress = rawBuffer.baseAddress?.assumingMemoryBound(to: CChar.self) else { return }
-            ghostty_surface_text(surface, baseAddress, UInt(rawBuffer.count))
-        }
+        terminalSurface?.sendText(chars)
 `#if` DEBUG
         CmuxTypingTiming.logDuration(
             path: "terminal.sendTextToSurface",
             startedAt: typingTimingStart,
             extra: "textBytes=\(chars.utf8.count)"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 10068 - 10085, The
sendTextToSurface(_:) function is writing raw bytes to ghostty_surface_text and
thus drops committed text when surface is nil; change it to call
TerminalSurface.sendText(_:) on the terminalSurface instance instead (use
terminalSurface.sendText(chars)) so the existing queuing and nil-surface
handling in TerminalSurface is reused; remove or replace the
data.withUnsafeBytes / ghostty_surface_text call and any direct utf8-to-CChar
buffer handling, but keep the existing debug probes and early returns for empty
data/surface checks, referencing sendTextToSurface(_:), terminalSurface,
TerminalSurface.sendText(_:), and ghostty_surface_text in your edits.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 10111-10113: The check for a single byte 0x9B is ineffective
because UTF-8 encodes U+009B as 0xC2 0x9B; update the sanitization loop in
GhosttyTerminalView.swift (the place iterating over bytes and calling
consumeLeadingCSISequence) to detect the two-byte UTF-8 sequence 0xC2 followed
by 0x9B (or equivalently decode the next scalar and check for U+009B) and treat
it the same as the CSI introducer: advance the index past both bytes and call
consumeLeadingCSISequence(in:bytes, from:); ensure you handle bounds so you
don't read past the end when peeking the next byte.

---

Outside diff comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 10068-10085: The sendTextToSurface(_:) function is writing raw
bytes to ghostty_surface_text and thus drops committed text when surface is nil;
change it to call TerminalSurface.sendText(_:) on the terminalSurface instance
instead (use terminalSurface.sendText(chars)) so the existing queuing and
nil-surface handling in TerminalSurface is reused; remove or replace the
data.withUnsafeBytes / ghostty_surface_text call and any direct utf8-to-CChar
buffer handling, but keep the existing debug probes and early returns for empty
data/surface checks, referencing sendTextToSurface(_:), terminalSurface,
TerminalSurface.sendText(_:), and ghostty_surface_text in your edits.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9eef4e59-78e2-4b10-ae82-0399e6b2b5c3

📥 Commits

Reviewing files that changed from the base of the PR and between 341aed8 and e63d3eb.

📒 Files selected for processing (2)
  • Sources/GhosttyTerminalView.swift
  • cmuxTests/CJKIMEInputTests.swift

Comment thread Sources/GhosttyTerminalView.swift Outdated

@coderabbitai coderabbitai 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.

♻️ Duplicate comments (1)
Sources/GhosttyTerminalView.swift (1)

9903-9905: ⚠️ Potential issue | 🟠 Major

Fix unreachable CSI check for UTF-8 U+009B (Line 9903).

text.utf8 never emits standalone 0x9B for valid Swift strings, so this branch won’t catch U+009B-prefixed CSI input.

Proposed fix
-            if byte == 0x9B {
-                index = consumeLeadingCSISequence(in: bytes, from: index + 1)
+            if byte == 0xC2, index + 1 < bytes.count, bytes[index + 1] == 0x9B {
+                index = consumeLeadingCSISequence(in: bytes, from: index + 2)
                 continue
             }
#!/bin/bash
python - <<'PY'
s = "\u009BC"
print("UTF-8 bytes for U+009BC:", list(s.encode("utf-8")))
PY
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 9903 - 9905, The branch
checking for a standalone byte 0x9B is unreachable because Swift's text.utf8
encodes U+009B as the two-byte sequence 0xC2 0x9B; update the loop that inspects
`bytes` to detect the UTF‑8 sequence for U+009B instead of a single 0x9B: where
you currently test `if byte == 0x9B { index = consumeLeadingCSISequence(in:
bytes, from: index + 1) ... }`, change the logic to check `if byte == 0xC2 &&
nextByte == 0x9B` (safely checking bounds), and call
`consumeLeadingCSISequence(in: bytes, from: index + 2)` so the CSI is consumed
correctly; keep the existing `consumeLeadingCSISequence` call semantics and
bounds checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 9903-9905: The branch checking for a standalone byte 0x9B is
unreachable because Swift's text.utf8 encodes U+009B as the two-byte sequence
0xC2 0x9B; update the loop that inspects `bytes` to detect the UTF‑8 sequence
for U+009B instead of a single 0x9B: where you currently test `if byte == 0x9B {
index = consumeLeadingCSISequence(in: bytes, from: index + 1) ... }`, change the
logic to check `if byte == 0xC2 && nextByte == 0x9B` (safely checking bounds),
and call `consumeLeadingCSISequence(in: bytes, from: index + 2)` so the CSI is
consumed correctly; keep the existing `consumeLeadingCSISequence` call semantics
and bounds checks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dc13dbf2-adcb-4626-9d1c-5db89d990fad

📥 Commits

Reviewing files that changed from the base of the PR and between e63d3eb and 4ac8a2e.

📒 Files selected for processing (2)
  • Sources/GhosttyTerminalView.swift
  • cmuxTests/CJKIMEInputTests.swift
✅ Files skipped from review due to trivial changes (1)
  • cmuxTests/CJKIMEInputTests.swift

@coderabbitai coderabbitai 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.

♻️ Duplicate comments (1)
Sources/GhosttyTerminalView.swift (1)

9903-9905: ⚠️ Potential issue | 🟠 Major

Make the escape sanitizer UTF-8-safe.

Line 9903 can never match valid String.utf8 input because U+009B is encoded as 0xC2 0x9B, not a lone 0x9B byte. Also Lines 9930-9938 consume one arbitrary byte after ESC / ESC O; if a malformed prefix is followed by non-ASCII text, that splits the first scalar and the sanitized result comes back as �.... Match C1 introducers in their UTF-8 form and only consume ASCII finals.

🐛 Proposed fix
-            if byte == 0x9B {
-                index = consumeLeadingCSISequence(in: bytes, from: index + 1)
+            if byte == 0xC2, index + 1 < bytes.count, bytes[index + 1] == 0x9B {
+                index = consumeLeadingCSISequence(in: bytes, from: index + 2)
                 continue
             }
@@
         case 0x4F:
             // SS3: ESC O final
-            return min(bytes.count, next + 2)
+            if next + 1 < bytes.count, bytes[next + 1] < 0x80 {
+                return next + 2
+            }
+            return min(bytes.count, next + 1)
@@
         default:
             // Single-character escape.
-            return min(bytes.count, next + 1)
+            return bytes[next] < 0x80 ? min(bytes.count, next + 1) : next

This read-only script reproduces the current byte walk and should show the raw 0x9B branch never fires for U+009B, while malformed ESC + é / ESC O + 你 inputs leave split UTF-8 suffixes behind:

#!/bin/bash
set -euo pipefail

sed -n '9898,9940p' Sources/GhosttyTerminalView.swift

python - <<'PY'
def consume_csi(bs, start):
    i = start
    while i < len(bs):
        b = bs[i]
        if 0x20 <= b <= 0x3F:
            i += 1
            continue
        if 0x40 <= b <= 0x7E:
            return i + 1
        break
    return i

def consume_escaped_string(bs, start):
    i = start
    while i < len(bs):
        b = bs[i]
        if b == 0x07:
            return i + 1
        if b == 0x1B:
            n = i + 1
            if n < len(bs) and bs[n] == 0x5C:
                return n + 1
            return i
        if b < 0x20 or b == 0x7F:
            return i + 1
        i += 1
    return len(bs)

def consume_escape(bs, start):
    n = start + 1
    if n >= len(bs):
        return len(bs)
    b = bs[n]
    if b == 0x5B:
        return consume_csi(bs, n + 1)
    if b == 0x4F:
        return min(len(bs), n + 2)
    if b in (0x50, 0x5D, 0x5E, 0x5F):
        return consume_escaped_string(bs, n + 1)
    return min(len(bs), n + 1)

def sanitize_like_current(text):
    bs = list(text.encode("utf-8"))
    idx = 0
    while idx < len(bs):
        b = bs[idx]
        if b == 0x1B:
            idx = consume_escape(bs, idx)
            continue
        if b == 0x9B:
            idx = consume_csi(bs, idx + 1)
            continue
        break
    suffix = bytes(bs[idx:])
    return bs, idx, list(suffix), suffix.decode("utf-8", errors="replace")

for label, text in {
    "U+009B + C": "\u009BC",
    "ESC + é": "\x1bé",
    "ESC O + 你": "\x1bO你",
}.items():
    bs, idx, suffix, decoded = sanitize_like_current(text)
    print(f"{label}: bytes={bs} idx={idx} suffix={suffix} decoded={decoded!r}")
PY

Also applies to: 9930-9938

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 9903 - 9905, The sanitizer
currently looks for a lone 0x9B byte and consumes one arbitrary byte after
ESC/ESC O, which breaks UTF‑8 (U+009B is 0xC2 0x9B and multi‑byte scalars get
split). Update the byte-walk to treat the C1 CSI introducer as the two‑byte
UTF‑8 sequence (0xC2, 0x9B) instead of byte == 0x9B (so when you see 0xC2
followed by 0x9B advance the index and call consumeLeadingCSISequence), and
tighten the ESC / ESC O handling to only consume ASCII CSI finals (bytes in
0x40..0x7E) rather than an arbitrary next byte; use the same final‑byte checks
used by consumeLeadingCSISequence/consumeCSI logic to decide how far to advance.
Ensure these changes reference the existing branches checking byte == 0x1B and
byte == 0x9B and the function consumeLeadingCSISequence.
🧹 Nitpick comments (1)
Sources/GhosttyTerminalView.swift (1)

9860-9878: Reuse TerminalSurface.sendText(_:) here.

This now duplicates the UTF-8 → ghostty_surface_text bridge already centralized in TerminalSurface.sendText(_:) / writeTextData(_:to:), and it bypasses the pending-text queue those helpers already provide. Reusing the wrapper keeps one code path and avoids dropping committed text during brief surface reattachment windows.

♻️ Proposed refactor
     fileprivate func sendTextToSurface(_ chars: String) {
-        guard let surface = surface else { return }
-        guard let data = chars.data(using: .utf8), !data.isEmpty else { return }
+        guard let terminalSurface, !chars.isEmpty else { return }
 `#if` DEBUG
         let typingTimingStart = CmuxTypingTiming.start()
 `#endif`
 `#if` DEBUG
         cmuxWriteChildExitProbe(
@@
             ],
             increments: ["probeInsertTextCount": 1]
         )
 `#endif`
-        data.withUnsafeBytes { rawBuffer in
-            guard let baseAddress = rawBuffer.baseAddress?.assumingMemoryBound(to: CChar.self) else { return }
-            ghostty_surface_text(surface, baseAddress, UInt(rawBuffer.count))
-        }
+        terminalSurface.sendText(chars)
 `#if` DEBUG
         CmuxTypingTiming.logDuration(
             path: "terminal.sendTextToSurface",
             startedAt: typingTimingStart,
             extra: "textBytes=\(chars.utf8.count)"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 9860 - 9878,
sendTextToSurface(_:) duplicates low-level UTF-8 → ghostty_surface_text logic
and bypasses the pending-text queue; replace its body to call
terminalSurface.sendText(chars) (or use TerminalSurface.writeTextData(_:to:) as
appropriate) after ensuring terminalSurface is non-nil, so text goes through the
centralized wrapper and its queue semantics; keep existing debug probes/metrics
but remove the direct data.withUnsafeBytes / ghostty_surface_text call and use
terminalSurface/TerminalSurface.sendText(_:)/writeTextData(_:to:) to perform the
actual send.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 9903-9905: The sanitizer currently looks for a lone 0x9B byte and
consumes one arbitrary byte after ESC/ESC O, which breaks UTF‑8 (U+009B is 0xC2
0x9B and multi‑byte scalars get split). Update the byte-walk to treat the C1 CSI
introducer as the two‑byte UTF‑8 sequence (0xC2, 0x9B) instead of byte == 0x9B
(so when you see 0xC2 followed by 0x9B advance the index and call
consumeLeadingCSISequence), and tighten the ESC / ESC O handling to only consume
ASCII CSI finals (bytes in 0x40..0x7E) rather than an arbitrary next byte; use
the same final‑byte checks used by consumeLeadingCSISequence/consumeCSI logic to
decide how far to advance. Ensure these changes reference the existing branches
checking byte == 0x1B and byte == 0x9B and the function
consumeLeadingCSISequence.

---

Nitpick comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 9860-9878: sendTextToSurface(_:) duplicates low-level UTF-8 →
ghostty_surface_text logic and bypasses the pending-text queue; replace its body
to call terminalSurface.sendText(chars) (or use
TerminalSurface.writeTextData(_:to:) as appropriate) after ensuring
terminalSurface is non-nil, so text goes through the centralized wrapper and its
queue semantics; keep existing debug probes/metrics but remove the direct
data.withUnsafeBytes / ghostty_surface_text call and use
terminalSurface/TerminalSurface.sendText(_:)/writeTextData(_:to:) to perform the
actual send.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f994db17-0738-464d-b9c5-844348e1861c

📥 Commits

Reviewing files that changed from the base of the PR and between 4ac8a2e and 9f34443.

📒 Files selected for processing (2)
  • Sources/GhosttyTerminalView.swift
  • cmuxTests/CJKIMEInputTests.swift
✅ Files skipped from review due to trivial changes (1)
  • cmuxTests/CJKIMEInputTests.swift

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 9888-9995: The sanitizer currently strips ESC sequences but can
slice multi-byte UTF-8 scalars and misses C1 control encodings; update
sanitizeExternalCommittedText and consume helpers so non-ASCII C1 sequences and
full UTF-8 scalars are consumed correctly: in sanitizeExternalCommittedText,
when bytes[index]==0xC2 detect any C2 0x80...0x9F pair and switch on the second
byte to handle U+008F (SS3 -> treat like ESC O and advance via
consumeLeadingEscapeSequence/SS3 logic), U+0090 (DCS), U+009B (CSI), U+009D
(OSC), U+009E (PM), U+009F (APC) (map DCS/OSC/PM/APC to
consumeLeadingEscapedStringSequence and CSI to consumeLeadingCSISequence),
advancing the index past the two-byte C1 UTF-8 prefix before calling the
respective consumer; and change consumeLeadingEscapeSequence's default
(single-character escape) to advance past the entire UTF-8 scalar starting at
the byte after ESC (implement a small helper that walks UTF-8 continuation bytes
so you return the index at the next scalar boundary rather than just next+1) so
non-ASCII chars are not left half-sliced (reference symbols:
sanitizeExternalCommittedText, consumeLeadingEscapeSequence,
consumeLeadingCSISequence, consumeLeadingEscapedStringSequence).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 45f3a53a-54c9-49d4-8bc6-fa14b3e92db6

📥 Commits

Reviewing files that changed from the base of the PR and between 9f34443 and 484c95c.

📒 Files selected for processing (2)
  • Sources/GhosttyTerminalView.swift
  • cmuxTests/CJKIMEInputTests.swift
✅ Files skipped from review due to trivial changes (1)
  • cmuxTests/CJKIMEInputTests.swift

Comment thread Sources/GhosttyTerminalView.swift
Comment thread Sources/GhosttyTerminalView.swift Outdated

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/GhosttyTerminalView.swift">

<violation number="1" location="Sources/GhosttyTerminalView.swift:9876">
P3: This adds a second copy of the text/control-key encoding logic instead of reusing the existing `TerminalSurface.sendInput` path. Keeping two copies of this mapping in a typing path is brittle and can cause behavior drift between input entry points.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/GhosttyTerminalView.swift

@coderabbitai coderabbitai 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.

♻️ Duplicate comments (1)
Sources/GhosttyTerminalView.swift (1)

9945-10049: ⚠️ Potential issue | 🟠 Major

Make the escape stripper UTF-8-safe and cover the remaining C1 introducers.

Line 9957 only recognizes U+009B. U+008F/U+0090/U+009D–U+009F still bypass sanitization, and Line 9995 still advances one raw byte after ESC, which can start the suffix in the middle of a UTF-8 scalar. Line 9974 then decodes that invalid slice with replacement characters instead of preserving or dropping the original text.

Suggested fix
     if byte == 0xC2 {
         let next = index + 1
-        if next < bytes.count, bytes[next] == 0x9B {
-            // U+009B (C1 CSI) is encoded as the UTF-8 byte pair C2 9B.
-            index = consumeLeadingCSISequence(in: bytes, from: next + 1)
-            continue
+        if next < bytes.count {
+            switch bytes[next] {
+            case 0x8F:
+                index = min(bytes.count, next + 2) // U+008F SS3
+                continue
+            case 0x90, 0x9D, 0x9E, 0x9F:
+                index = consumeLeadingEscapedStringSequence(in: bytes, from: next + 1)
+                continue
+            case 0x9B:
+                index = consumeLeadingCSISequence(in: bytes, from: next + 1) // U+009B CSI
+                continue
+            default:
+                break
+            }
         }
     }
@@
         case 0x50, 0x5D, 0x5E, 0x5F:
             // DCS/OSC/PM/APC: consume until BEL/ST or EOF.
             return consumeLeadingEscapedStringSequence(in: bytes, from: next + 1)
         default:
-            // Single-character escape.
-            return min(bytes.count, next + 1)
+            // Don't split the first UTF-8 scalar after ESC.
+            return bytes[next] < 0x80 ? min(bytes.count, next + 1) : next
         }
@@
             if byte == 0x07 {
                 return index + 1
             }
+
+            if byte == 0xC2 {
+                let next = index + 1
+                if next < bytes.count, bytes[next] == 0x9C {
+                    return next + 1 // U+009C ST
+                }
+            }
@@
-        return String(decoding: bytes[index...], as: UTF8.self)
+        return String(bytes: bytes[index...], encoding: .utf8) ?? ""

Run the following to confirm the missing C1 branches and the mid-scalar slice risk:

#!/bin/bash
python - <<'PY'
print("C1 UTF-8 encodings:")
for cp in [0x8F, 0x90, 0x9B, 0x9C, 0x9D, 0x9E, 0x9F]:
    print(hex(cp), list(chr(cp).encode("utf-8")))
print("ESC + あ bytes:", [0x1B] + list("あ".encode("utf-8")))
PY

rg -n "0x8F|0x90|0x9B|0x9C|0x9D|0x9E|0x9F|return min\\(bytes\\.count, next \\+ 1\\)|String\\(decoding: bytes\\[index\\.\\.\\.\\], as: UTF8\\.self\\)" Sources/GhosttyTerminalView.swift
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 9945 - 10049,
sanitizeExternalCommittedText currently only recognizes U+009B and may slice in
the middle of a multi-byte UTF‑8 scalar (causing replacement chars) and also
misses other C1 introducers; update sanitizeExternalCommittedText to treat all
C1 bytes (0x8F, 0x90, 0x9B–0x9F) like 0x9B by routing them into
consumeLeadingCSISequence or the appropriate handler, and modify
consumeLeadingEscapeSequence (the default single-character escape branch) to
advance to the next UTF‑8 scalar boundary instead of advancing exactly one raw
byte (implement a small helper that skips UTF‑8 continuation bytes 0x80...0xBF);
finally ensure the final slice in sanitizeExternalCommittedText is taken from a
UTF‑8-valid boundary (e.g., use String(bytes[index...], encoding: .utf8) or fall
back to dropping the prefix) so you don’t decode mid‑scalar and produce
replacement characters.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 9945-10049: sanitizeExternalCommittedText currently only
recognizes U+009B and may slice in the middle of a multi-byte UTF‑8 scalar
(causing replacement chars) and also misses other C1 introducers; update
sanitizeExternalCommittedText to treat all C1 bytes (0x8F, 0x90, 0x9B–0x9F) like
0x9B by routing them into consumeLeadingCSISequence or the appropriate handler,
and modify consumeLeadingEscapeSequence (the default single-character escape
branch) to advance to the next UTF‑8 scalar boundary instead of advancing
exactly one raw byte (implement a small helper that skips UTF‑8 continuation
bytes 0x80...0xBF); finally ensure the final slice in
sanitizeExternalCommittedText is taken from a UTF‑8-valid boundary (e.g., use
String(bytes[index...], encoding: .utf8) or fall back to dropping the prefix) so
you don’t decode mid‑scalar and produce replacement characters.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8e1c38c1-acc9-4c5a-8a94-91a905673f3f

📥 Commits

Reviewing files that changed from the base of the PR and between 484c95c and c070205.

📒 Files selected for processing (2)
  • Sources/GhosttyTerminalView.swift
  • cmuxTests/CJKIMEInputTests.swift
✅ Files skipped from review due to trivial changes (1)
  • cmuxTests/CJKIMEInputTests.swift

@austinywang
austinywang merged commit e05d425 into main Apr 1, 2026
16 checks passed
@austinywang
austinywang deleted the issue-2400-speakly-voice-stray-c branch April 1, 2026 08:56
@austinywang austinywang mentioned this pull request Apr 6, 2026
3 tasks

This branch was successfully deployed

1 active deployment
Preview — b71d6fbc Deployed Apr 1, 2026 by vercel[bot]
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.

Extra 'C' character inserted at beginning of text when using Genspark Speakly voice dictation

1 participant