Skip to content

fix: Fix input handling for ESC-char as a meta prefix.#895

Merged
gdamore merged 2 commits intomainfrom
gdamore-meta-key-timer
Dec 8, 2025
Merged

fix: Fix input handling for ESC-char as a meta prefix.#895
gdamore merged 2 commits intomainfrom
gdamore-meta-key-timer

Conversation

@gdamore
Copy link
Owner

@gdamore gdamore commented Dec 8, 2025

This requires the caller of the input processor to handle their own delays, but they can check whether there is unfinished work by calling inputProcessor.Waiting. If that is true they should call it again in a while, otherwise they can sleep until new characters arrive.

Summary by CodeRabbit

  • New Features

    • Controlled input scanning with an explicit Scan() and a Waiting() indicator; timer-based debouncing to delay processing briefly when input is pending.
  • Bug Fixes

    • More reliable UTF‑8, escape/Alt and special‑key detection with inline timeout handling and improved per-path buffering to reduce dropped/duplicated keys.
  • Tests

    • Expanded test coverage for special keys, UTF‑8 edge cases, timeout behavior and concurrent input; added additional synchronization checks.

✏️ Tip: You can customize this high-level summary in your review settings.

This requires the caller of the input processor to handle their
own delays, but they can check whether there is unfinished work
by calling inputProcessor.Waiting.  If that is true they should call
it again in a while, otherwise they can sleep until new characters arrive.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 8, 2025

Walkthrough

Input parsing was renamed and refactored: inputProcessorinputParser, inpStateinputState (ist* constants). New buffers and timing fields added, Scan/Waiting APIs introduced, esc-timeout logic moved inline, and tScreen gains a 100ms debounce to call input.Scan() when parser is non‑idle.

Changes

Cohort / File(s) Summary
Input parser core
input.go
Renamed types/constants (inputProcessorinputParser, inpStateinputState, inpState*ist*). Replaced flat buffers with utfBuf, strBuf, csiParams, csiInterm, escChar, keyTime. Merged esc timeout into scan path, added Waiting() and Scan() methods, and updated handlers (OSC, CSI, mouse, WinKey) to use new fields.
Input tests
input_test.go
Tests renamed to TestInput*, updated to call newInputParser(...), added timing-aware assertions and follow-up ip.Scan() calls, added Esc‑Esc case and adjusted loops/timeouts to match new timing behavior.
Screen integration / debouncing
tscreen.go
tScreen.input changed to *inputParser; initialization switched to newInputParser(...). Added time import and a 100ms debounce timer that schedules t.input.Scan() when input.Waiting() is true and clears it when idle.

Sequence Diagram(s)

sequenceDiagram
    participant T as tScreen (main loop)
    participant P as inputParser
    participant Q as eventQ
    participant Timer as 100ms timer

    T->>P: feed bytes (ScanUTF8 / write)
    P->>Q: post immediate events (if any)
    alt P.Waiting() == true
        T->>Timer: start 100ms timer
        Timer-->>T: timer fires
        T->>P: t.input.Scan()  (timed follow-up)
        P->>Q: post delayed events (Escape, Alt+Rune, completed sequences)
    else P idle
        T-->>Timer: stop/clear timer
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Pay close attention to input.go: state-machine renames, new buffers, and inline timing — verify correctness and absence of regressions.
  • Review tscreen.go timer integration for race conditions and correct timer lifecycle.
  • Inspect input_test.go timing assertions for flakiness and ensure tests reliably exercise new Scan/Waiting behavior.

Possibly related PRs

Poem

🐰
I munched the bytes beneath the moon,
Timers thumped a gentle tune,
Escapes now hop then settle slow,
Runes and Alt in tidy row,
A rabbit cheers the parser's tune! 🥕

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: Fix input handling for ESC-char as a meta prefix' directly addresses the main purpose of this PR, which is refactoring the input processor to properly handle ESC characters as meta prefixes with new timing-based logic.
Docstring Coverage ✅ Passed Docstring coverage is 84.62% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch gdamore-meta-key-timer

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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (3)
tscreen.go (1)

20-37: Timer-driven rescan using input.Waiting looks correct; optional improvement for timer reuse

The pattern of setting ta = time.After(100 * time.Millisecond) only when t.input.Waiting() is true and clearing it otherwise correctly defers t.input.Scan() until escape/meta sequences have had time to disambiguate, and the nil-channel case in the select safely disables the timer branch. The only trade-off is that repeated ESC-prefix traffic can create short-lived time.After timers whose channels are never read once ta is reassigned; if this path ever becomes hot, consider switching to a single time.Timer instance with Stop/Reset and reusing timer.C to avoid extra allocations and stray timers, though it’s not required for correctness here.

Also applies to: 923-952

input.go (2)

84-88: Consider documenting the new Waiting contract for other potential callers

Waiting() currently reports only ip.state != inpStateInit, which is exactly what tscreen.mainLoop needs to decide whether to schedule a delayed Scan(). Since this is now the public hook that replaces the old internal timer, a short doc comment clarifying that “true” means the caller should arrange to call Scan() again after a small delay (and that no internal timer will fire) would help future users of inputProcessor avoid assuming it is self‑timing.


69-70: escTimeout/timer/expire appear vestigial; consider consolidating or removing

With timeouts now handled inside scan() via keyTime and the caller-managed Scan() calls, escTimeout() and the timer/expire fields no longer seem to be wired up from this file. If there are no remaining external callers of escTimeout, dropping these fields and the method (or delegating the end-of-scan timeout logic to escTimeout to keep a single implementation) would reduce confusion around which path actually governs ESC/meta timeouts; otherwise, it’s worth double-checking that any remaining users see behavior consistent with the new escChar‑based rules.

Also applies to: 121-137

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 866fd71 and 7bf328b.

📒 Files selected for processing (3)
  • input.go (9 hunks)
  • input_test.go (5 hunks)
  • tscreen.go (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
input.go (1)
key.go (6)
  • NewEventKey (249-308)
  • KeyEsc (516-516)
  • ModNone (325-325)
  • KeyRune (339-339)
  • ModAlt (322-322)
  • KeyEscape (517-517)
input_test.go (1)
key.go (4)
  • KeyEscape (517-517)
  • ModAlt (322-322)
  • EventKey (45-50)
  • Key (332-332)
🔇 Additional comments (4)
input_test.go (3)

325-328: Explicit ip.Scan() after a short delay aligns tests with the new timeout contract

Updating the UTF‑8 and empty-input tests to sleep briefly and then call ip.Scan() ensures they still pass now that timeout handling is driven by callers instead of an internal timer; this accurately reflects the new Waiting/Scan contract and keeps the assertions meaningful without changing the semantics under test.

Also applies to: 354-357


372-384: MultipleNullBytes loop still validates behavior correctly

Switching to for i := range 3 while draining three events and checking each for KeyRune + " " + ModCtrl preserves the original intent and keeps the test straightforward, with no behavioral regressions.


527-528: Good coverage for ESC‑ESC meta prefix and timeout-driven delivery

Adding the "Esc-Esc" case expecting KeyEscape with ModAlt, plus the timeout branch that calls ip.Scan() and immediately rechecks evch, exercises the new behavior where ESC‑prefixed meta sequences are only emitted after a delay. This pattern correctly handles both immediate and timeout-driven key events and should catch regressions in meta‑ESC handling.

Also applies to: 578-603

input.go (1)

62-70: Waiting/Scan API and timeout flush logic look coherent with the new caller-driven model

Tracking escChar and keyTime inside scan(), exposing Waiting() as state != inpStateInit, and adding the Scan() wrapper that takes the mutex give callers (like tScreen.mainLoop) a clear, race-free way to drive timeout processing from outside the processor. The end-of-scan timeout block that, after 50ms of inactivity, emits either KeyEscape (for inpStateEsc, relying on ip.escaped to add ModAlt for ESC‑ESC) or an Alt‑rune based on escChar, then resets state to inpStateInit, matches the intended “ESC as meta prefix” semantics without disturbing unambiguous CSI/SS3 sequences.

Also applies to: 417-422, 596-605, 952-956

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
tscreen.go (1)

923-952: Timer‑driven Scan() integration with Waiting() is sound; consider a named timeout constant

The ta <-chan time.Time state plus time.After(100 * time.Millisecond) when t.input.Waiting() is true correctly defers Scan() until ambiguous input (e.g. lone ESC) has aged, and the nil channel disables the case when idle. To avoid a magic number and keep this aligned with the parser/tests, you may want to promote the 100ms delay to a shared constant.

input_test.go (1)

306-437: UTF‑8 and edge‑case tests are accurate; time‑based behavior is well covered

The UTF‑8 checks (TestInputUTF8Characters) and edge‑case subtests in TestInputEdgeCases (empty input, multiple NULs, 0x1F/0x20 boundaries) correctly exercise the parser’s rune handling and ensure no spurious events are emitted. The short sleeps and timeouts used here are reasonable for CI but do tie the tests slightly to wall‑clock timing; keep that in mind if you ever lengthen the internal timeout in scan().

input.go (1)

711-789: Verify for range over integers against the project’s minimum Go version

The loop

for range rpt {
    if key != KeyRune || chr != 0 {
        ip.post(NewEventKey(key, string(chr), mod))
    }
}

(and similar for range 3/10 usages in input_test.go) relies on Go’s integer range support, which is only available in Go 1.22+. If tcell is still expected to build on earlier Go releases, these loops will not compile and should be rewritten as traditional index loops (e.g., for i := 0; i < rpt; i++ { … }). Otherwise, ensure go.mod and documentation clearly state Go 1.22+ as the minimum supported version.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7bf328b and 48166e2.

📒 Files selected for processing (3)
  • input.go (13 hunks)
  • input_test.go (20 hunks)
  • tscreen.go (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
input_test.go (2)
event.go (1)
  • Event (23-26)
key.go (4)
  • KeyEscape (517-517)
  • ModAlt (322-322)
  • EventKey (45-50)
  • Key (332-332)
🔇 Additional comments (11)
tscreen.go (1)

175-176: Switch to inputParser and SetSize wiring looks correct

Using newInputParser(t.eventQ) and forwarding terminal resize notifications via t.input.SetSize(ws.Width, ws.Height) cleanly plugs the new parser into the existing screen lifecycle; no issues spotted here.

Also applies to: 255-258, 848-849

input_test.go (4)

22-171: Control/NULL/printable character tests match the parser’s scan semantics

TestInputNullByte, TestInputControlKeys, and TestInputNullVsOtherControlChars correctly reflect how scan() maps 0x00–0x1F into EventKey values (including KeyRune " " with ModCtrl for NUL and KeyCtrlAKeyCtrlZ via NewEventKey), and give good regression coverage around the control/printable boundary.


173-305: Printable, special‑key, and sequential‑input tests look consistent with current behavior

TestInputPrintableCharacters, TestInputSpecialKeys, and TestInputSequentialInput all assert results that line up with the new scan() logic for ASCII runes, backspace/tab/enter, and mixed control/printable sequences; no issues seen.


439-517: Concurrency and state‑transition tests validate mutex and state‑machine behavior

TestInputConcurrentAccess confirms that ScanUTF8 is safe under concurrent callers (thanks to the internal mutex), and TestInputStateTransitions verifies that the NUL fix doesn’t leave the parser in a non‑initial state. Both tests align with the implementation and provide useful regression coverage.


519-606: TestSpecialKeys correctly exercises ESC vs ESC‑ESC meta‑prefix semantics

The Esc and Esc-Esc cases, combined with the “timeout then ip.Scan()” pattern, accurately mirror the new lone‑ESC vs meta‑ESC behavior in scan(). Other CSI and SS3 cases (arrows, function keys, modifiers) match the csiAllKeys/ss3Keys mappings. No changes needed here.

input.go (6)

37-79: inputState and inputParser refactor is coherent with downstream usage

The new inputState enum (istInit, istEsc, istCsi, etc.) and the expanded inputParser fields (buf, utfBuf, strBuf, csiParams, escChar, keyTime, nested, …) align with how scan(), ScanUTF8(), handleCsi(), and handleWinKey() operate. The structure reads cleanly and matches the intended state‑machine design.


94-123: SetSize and post behavior remains consistent and safe

Forwarding SetSize to a nested parser when present and using a goroutine to update rows/cols and post NewEventResize under the mutex preserves prior semantics, and post’s handling of escaped (Alt meta) and paste start/end keys looks correct in the new parser context.


403-592: ESC/meta and timeout logic in scan() correctly handles lone ESC, ESC‑ESC, and ambiguous prefixes

The reworked scan():

  • Treats a single ESC as entering istEsc with escChar=0, then, after >50ms with no further input, posts KeyEscape with no modifiers.
  • Treats ESC ESC as setting escaped=true in istEsc; on timeout it posts KeyEscape and upgrades it to ModAlt via post(), giving the expected Meta‑Escape.
  • Uses escChar plus the timeout path to resolve ambiguous prefixes like ESC [ or ESC ] into Alt+[ or Alt+] when no full CSI/OSC sequence arrives.
  • Resets the state machine after a gap (ip.state = istInit), preventing stale state from leaking across keystrokes.

This matches the PR’s intent and the updated tests; no changes needed here.


594-603: handleOsc correctly resets state and posts clipboard events

By setting ip.state = istInit up front and decoding OSC 52 clipboard payloads via base64 into NewEventClipboard, handleOsc keeps the state machine consistent while preserving existing OSC‑52 behavior.


633-709: Mouse handling with SGR (CSI <... M/m) remains correct with bounds clipping

handleMouse’s use of rows/cols to clip coordinates, plus its treatment of motion vs scroll bits and modifier extraction, is consistent with standard XTerm mouse reporting and with existing tcell semantics. The refactor to a method on inputParser looks fine.


913-943: Locking strategy for ScanUTF8/Scan keeps the state machine race‑free

Accumulating bytes into utfBuf, decoding into buf, and then calling scan() under ip.l in ScanUTF8, plus reusing the same scan() under lock in Scan(), ensures state, escChar, and keyTime are updated atomically. This matches how Waiting() reads state and should prevent subtle races between input ingestion and timeout‑driven scans.

This is just some stylistic cleanups for internal symbols and improved doc comments.
@gdamore gdamore force-pushed the gdamore-meta-key-timer branch from 48166e2 to 8a470ee Compare December 8, 2025 01:39
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
input.go (2)

582-582: Consider: Document the inter-character timeout threshold.

The 50ms threshold here is the inter-character timeout for escape sequences, while tscreen uses a 100ms timer for periodic retries. The interaction is subtle:

  • If characters arrive >50ms apart, timeout triggers on next scan() call
  • The 100ms external timer ensures Scan() is eventually called when Waiting() is true

This works correctly but the coordination between the two timeouts could be clearer in comments.


913-935: LGTM: UTF-8 handling refactored to use byte buffer.

The refactoring accumulates bytes in utfBuf and decodes runes incrementally, correctly handling both complete and partial UTF-8 sequences.

Minor: The comment at line 919 says "fast path, basic ascii" but the check < 0x7F includes control characters (0x00-0x1F). Consider "fast path, single-byte characters" for clarity.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 48166e2 and 8a470ee.

📒 Files selected for processing (3)
  • input.go (13 hunks)
  • input_test.go (20 hunks)
  • tscreen.go (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
input_test.go (2)
event.go (1)
  • Event (23-26)
key.go (4)
  • KeyEscape (517-517)
  • ModAlt (322-322)
  • EventKey (45-50)
  • Key (332-332)
input.go (2)
event.go (1)
  • Event (23-26)
key.go (6)
  • NewEventKey (249-308)
  • KeyRune (339-339)
  • ModNone (325-325)
  • KeyBacktab (361-361)
  • KeyEscape (517-517)
  • ModAlt (322-322)
🔇 Additional comments (8)
tscreen.go (3)

33-33: LGTM: Time package import added for timer support.

The time import is necessary for the new timer-based debounce logic in mainLoop.


175-175: LGTM: Type rename from inputProcessor to inputParser.

The rename is consistently applied across field declaration and initialization.

Also applies to: 257-257


926-952: LGTM: Timer-based debounce correctly implements caller-managed delays.

The implementation correctly handles the new API contract:

  • Checks Waiting() after each input batch
  • Schedules a 100ms timer when parser is mid-sequence
  • Disables timer (sets ta = nil) when parser returns to idle
  • Calls Scan() on timeout to finalize incomplete sequences

The concurrency is safe since both Waiting() and Scan() use internal locking.

input_test.go (2)

22-26: LGTM: Test updates reflect the API rename.

All tests consistently updated from newInputProcessor to newInputParser and test names simplified from TestInputProcessor* to TestInput*.

Also applies to: 52-55, 99-99, 131-135, 173-175, 197-197, 223-224, 241-241, 264-267, 306-307, 323-323, 348-352, 368-368, 389-389, 415-415, 439-443, 482-486, 572-572


326-327: LGTM: Tests validate deferred Scan() behavior and ESC-ESC handling.

The additions correctly test the new API contract:

  • Explicit Scan() calls after delays validate incomplete sequence handling
  • Esc-Esc test case (line 528) confirms Alt-modified escape key behavior
  • TestSpecialKeys fallback logic mirrors the tscreen timer pattern

Also applies to: 355-356, 528-528, 587-602

input.go (3)

37-79: LGTM: Type rename and new fields support the refactoring.

The rename from inputProcessor to inputParser is consistently applied. New fields (utfBuf, strBuf, csiParams, csiInterm, escChar, keyTime, nested) are appropriately scoped private fields that support the timeout handling and buffer management refactoring.


81-92: LGTM: Waiting() correctly exposes parser state.

The method safely returns whether the parser is mid-sequence (state != istInit), enabling callers to implement timeout-based finalization. The mutex ensures thread-safe access.


937-943: LGTM: Scan() correctly exposes deferred processing.

The method provides the public API for callers to trigger timeout-based finalization after detecting Waiting() is true. The lock/unlock pattern matches ScanUTF8.

Comment on lines +582 to 591
if ip.state != istInit && time.Since(ip.keyTime) > time.Millisecond*50 {
if ip.state == istEsc {
ip.post(NewEventKey(KeyEscape, "", ModNone))
} else if ec := ip.escChar; ec != 0 {
ip.post(NewEventKey(KeyRune, string(ec), ModAlt))
} else {
}
// if we take too long between bytes, reset the state machine.
ip.state = istInit
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's understand the context around lines 582-591 in input.go
head -n 600 input.go | tail -n 50

Repository: gdamore/tcell

Length of output: 1417


🏁 Script executed:

# Let's see the full scan() method to understand state transitions
sed -n '380,600p' input.go | head -n 100

Repository: gdamore/tcell

Length of output: 2284


🏁 Script executed:

# Look for tests related to escape sequences and timeouts
grep -n "50.*[Mm]illisecond\|timeout\|Escape\|ESC.*\[" input_test.go | head -20

Repository: gdamore/tcell

Length of output: 496


🏁 Script executed:

# Search for any documentation comments about escChar or timeout behavior
grep -B5 -A5 "escChar\|keyTime" input.go | head -40

Repository: gdamore/tcell

Length of output: 1576


🏁 Script executed:

# Search for tests that specifically test incomplete CSI sequences with timeouts
grep -n "CSI\|escChar\|Alt.*\[" input_test.go | head -30

Repository: gdamore/tcell

Length of output: 1542


🏁 Script executed:

# Look for any comments or documentation about the escChar and timeout behavior
grep -B3 -A3 "if ec := ip.escChar" input.go

Repository: gdamore/tcell

Length of output: 312


🏁 Script executed:

# Check if there are integration tests or behavior documentation
grep -r "incomplete.*sequence\|timeout.*escape\|CSI.*timeout" . --include="*.go" --include="*.md"

Repository: gdamore/tcell

Length of output: 39


🏁 Script executed:

# Look at the actual state machine transitions to understand if timeout behavior is documented
sed -n '380,450p' input.go | grep -A2 -B2 "case istEsc"

Repository: gdamore/tcell

Length of output: 109


Document or reconsider timeout behavior for incomplete escape sequences.

The timeout logic (lines 582–591) posts incomplete escape sequences as Alt-modified keys. For example, if a user types ESC [ (CSI introducer) and the sequence doesn't complete within 50ms, the code emits Alt+[ as a single event. While this is internally consistent with treating ESC as a meta/Alt prefix, it is undocumented and has no test coverage.

This behavior could surprise downstream applications that expect either separate ESC and [ events or sequence rejection. Consider:

  • Adding test cases for incomplete sequences that timeout
  • Adding inline documentation explaining why the escChar is emitted as Alt-modified rather than discarding the sequence or emitting separate events
🤖 Prompt for AI Agents
input.go around lines 582-591: the timeout path currently converts an incomplete
escape sequence into an Alt-modified key (emitting Alt+<char> after 50ms), which
is undocumented and untested; add unit tests that cover timeout cases (e.g.,
feed ESC then '[' with a delay >50ms and assert Alt+'[' is emitted), and add an
inline comment above this block explaining the intentional behavior and
rationale (why we treat ESC as Meta/Alt and choose to emit Alt+char on timeout
rather than splitting or discarding), plus a TODO note suggesting making this
behavior configurable if desired.

@gdamore gdamore merged commit f8fdf10 into main Dec 8, 2025
13 checks passed
@gdamore gdamore deleted the gdamore-meta-key-timer branch December 8, 2025 04:21
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.

1 participant