Skip to content

fix(terminal): keep the key bar live while the input line composes - #962

Merged
devswha merged 3 commits into
mainfrom
fix/keybar-live-while-composing
Oct 13, 2026
Merged

devswha merged 3 commits into
mainfrom
fix/keybar-live-while-composing

Conversation

@devswha

@devswha devswha commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Problem

On a phone in input-line mode (the touch default), the whole key bar goes grey and ignores taps while a word is still composing in the input line. With Gboard that is most of the time, because every word composes until a space; with a Korean or Japanese IME it lasts until the syllable or phrase commits. Esc and ^C cannot stop an agent, an arrow cannot answer a menu, Ctrl/Alt/Shift cannot be armed, and the keyboard-mode toggle is dead.

Cause

PaneTerminal passed onComposing={setComposing} to TerminalInput, so the input line's compositionstart/compositionend wrote the same composingRef and composing state that guard xterm's deferred IME commit. That flag disables <KeyBar> and short-circuits pressKey, toggleModifier and toggleDirect. The guard exists so a key-bar key cannot overtake xterm's pending commit, but the input line's text never goes through xterm or onData: it is submitted separately with socket.submit. There was nothing to protect, only taps to drop.

Fix

  • PaneTerminal.tsx: stop wiring TerminalInput's composition into setComposing. The xterm host compositionstart/compositionend listeners (and the pane reset) stay the only writers of composingRef/compositionCommitPendingRef, so the xterm deferred-commit gate is unchanged.
  • TerminalInput.tsx: remove the now unused onComposing prop and its unmount cleanup. The line keeps its own local composing ref that guards its Send button and Enter.
  • docs/terminal-input.md: the key bar waits only for a direct-typing (xterm) composition; an input-line composition leaves it live.
  • Review follow-up (ca26dd6): the direct-typing toggle is the one key that still waits for an input-line composition. Switching mid-word would unmount the line with the IME composition open, and the word could be lost or typed into the pane. TerminalInput reports its composition through onComposing again. PaneTerminal tracks it apart from the xterm composition, toggleDirect returns early on it, and KeyBar gets directDisabled, which greys out only that key.

Tests

  • New phone block in scripts/terminal-input-regression.ts (390x844, touch, line mode): fill the line, dispatch compositionstart on .terminal-input-text, assert Esc, ^C and Ctrl are enabled, tap Esc and ^C, expect exactly two input frames \x1b and \x03, and the line's value unchanged.
  • bun run check run --build bun scripts/terminal-input-regression.ts: PASS (all three blocks). With the two source files reverted the new block fails on "Esc stays live while the input line composes".
  • bun run typecheck: clean. bun run test:unit: 3069 pass, 0 fail, 8 skip. scripts/changelog-fold.test.ts, scripts/pages-gate.test.ts, scripts/conventions.test.ts: pass.
  • Not run: scripts/ui-regression.ts as a whole (it calls checkTerminalInput, so CI runs the new block there too), check full/concurrent.
  • The phone regression also checks that [data-key="direct"] is disabled during the composition and that a forced tap on it leaves the line, its text and the pane untouched. Before the follow-up fix, that check failed with "the direct-typing toggle waits for the line's composition".

Notes

🤖 Generated with Claude Code

The input line's compositionstart/compositionend drove the same
composingRef and composing state that guard xterm's deferred IME commit,
so a word in progress (most of the time with Gboard, and every syllable
with a Korean or Japanese IME) greyed out the whole key bar and made
pressKey, toggleModifier and toggleDirect return early. The input line's
text never passes through xterm, so there is nothing for a key-bar key
to overtake. Only the xterm host listeners now set the guard; the input
line keeps its own Send guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 41 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: devswha/herdr-web-ui/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c83df646-019a-453a-9bfc-8938a78e1e23
📥 Commits

Reviewing files that changed from the base of the PR and between 1322830 and fdec654.

📒 Files selected for processing (6)
  • changelog.d/keybar-live-while-composing.md
  • docs/terminal-input.md
  • scripts/terminal-input-regression.ts
  • src/components/KeyBar.tsx
  • src/components/PaneTerminal.tsx
  • src/components/TerminalInput.tsx
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

…poses

Review follow-up: the input line's composition no longer greys out the key
bar, so the direct-typing toggle became live mid-word and could unmount
the line with an IME composition still open. Track the line's composition
separately, block toggleDirect on it and disable only the direct key.
The phone regression now taps that key mid-composition and checks the
line, its text and the pane are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	src/components/PaneTerminal.tsx
@devswha
devswha merged commit 068a6a6 into main Oct 13, 2026
16 of 17 checks passed
@devswha
devswha deleted the fix/keybar-live-while-composing branch October 13, 2026 15:53
devswha added a commit that referenced this pull request Oct 13, 2026
… ping (#967)

## Problem
A WebSocket can still report `OPEN` after its TCP path is gone, for
example when a phone moves from Wi-Fi to cellular or wakes on a changed
network. No close event arrives for tens of seconds to minutes. In that
time:
- the terminal stops updating while the header still says connected, and
no reconnecting banner shows;
- keys go to `rawSend` on the dead socket and `sendInput()` returns
true, so PaneTerminal never keeps them as a held draft and they are
lost;
- a composer send waits the full 90 s submit timeout;
- `wake()` (visibilitychange / pageshow / online) leaves an `OPEN`
socket alone.

## Cause
`HerdrSocket` treats `readyState === OPEN` as proof that the connection
is alive. The protocol has no app-level ping. Bun's own pings and idle
timeout only tell the server about a dead peer, and the browser never
sees them.

## Fix
- **Protocol** (`shared/protocol.ts`): new `ClientMessage` `ping {id}`,
`ServerMessage` `pong {id}` and `ServerFeature` `"ping"`. The doc
comments on both frames are new.
- **Server** (`server/index.ts`): answers `ping` with `pong` for any
role and lists `"ping"` in its snapshot. A malformed id gets an
`invalid_ping` error frame, not a pong.
- **Remote relay:** no code change. `MachineRelay` already forwards
frames as they are, so the remote PC's own snapshot decides whether the
client pings. The contract test proves the pass-through.
- **Demo** (`site/demo/transport.ts`): lists `"ping"` and answers it the
way the server does.
- **Client** (`src/lib/ws.ts`):
- Tracks `lastFrameAt`. On a bridge whose last snapshot listed `"ping"`,
it probes an `OPEN` socket on wake (visible, pageshow, online) and after
20 s without a frame while the page is visible (checked every 5 s). The
flag is remembered across reconnects, so a socket whose snapshot never
arrives (a relay path that dies right after the upgrade, a failed
snapshot) is probed too.
- Only the `pong` of a ping in flight ends a probe. Any frame still
postpones the idle probe, but a frame the browser buffered before a
sleep, or a pong with another id, does not prove the path is alive. The
probe ends before handlers run, so `canInput()` already reads true for
them.
- No pong within 3 s sends a second ping; no pong to that drops the
socket on the client side: `socket = null`, the disconnect handlers run,
submits settle as `disconnected`, `close()` is called, and `connect()`
runs at once with retries reset. The open handler then replays the role
and attaches. Each probe that ends in a drop doubles the per-ping
deadline (cap 12 s) until a pong comes back, so a slow relayed link is
not dropped over and over.
- A dropped socket is kept in `endedEarly` (now a `WeakSet`, shared with
`wake()`), so a 4008 close that arrives after the drop still stops
output and closes the replacement. A socket that is `CLOSING` when the
deadline hits is left to its close event (same rule as #954's `wake()`).
- Hiding the page cancels a probe; showing it starts a fresh one. A
deadline that fires more than 2 s late (the page was frozen and the
timer ran before the queued pong) pings again instead of dropping.
- While a probe waits, `canInput()` refuses, so typing goes to the held
draft. `submit()`, `pendingAction()` and `sendSecret()` wait the probe
out after their snapshot wait and right before sending; if the socket is
dropped they settle as `disconnected` having sent nothing (the secret is
still cleared). The composer no longer reports offline during a routine
probe.
- New explicit `ConnectionState` (`idle | connecting | open | probing |
waiting | stopped | closed`), exposed as `state`. `connected` still
reads the socket itself, so the banner does not flash on every tab
switch.
- **Held draft** (`src/components/PaneTerminal.tsx`): once input is held
for a pane, all later typing joins the draft until Send or Discard, also
after the terminal is ready again. Before this, keys refused during a
probe (or a disconnect) were held while the next keys after the pong
went straight to the pty: `echo ` held and `rm -rf build⏎` run. Text,
pastes, Backspace and anything with a line break join the draft; Enter
is left out of it, a sticky-modifier chord is refused with a note (new
string, translated in ko/ja/zh/tr), and the input line's Enter is
refused while a draft is held. Other control keys (Ctrl+C, Escape from
the Stop button, Tab, arrows) still reach the program, so an interrupt
never waits on the draft.
- `src/AGENTS.md`: the WS client row records the probe rules, and a new
row records the held-draft rule.

## Tests
- `src/lib/ws.test.ts` (fake socket and fake timers):
- an `OPEN` socket with no pong gets a second ping at 3 s and is dropped
at 6 s. The disconnect handler runs once, a pending submit settles as
`disconnected`, a new socket starts with no backoff and its open replays
role and attach at the saved geometry, and the late close of the old
socket changes nothing;
- during a probe, `sendInput` and `sendKeys` are refused; a buffered
`pane-status` frame and a pong with another id leave it probing; the
matching pong restores input; a second wake sends no second ping;
  - a socket that only delivers stale frames during a probe is dropped;
- `submit`, `pendingAction(steer)` and `sendSecret` started during a
probe send nothing until the pong, then each sends once;
- a submit already waiting on its snapshot when `wake()` runs, and a
secret, settle as `disconnected` with nothing sent when the probe drops
the socket;
- a 4008 close after a probe drop still stops output and closes the
replacement;
- a deadline that runs out while the page is frozen pings again, and the
pre-freeze pong no longer counts;
- hiding the page cancels the probe (no drop in 60 s), showing it starts
a fresh one;
- a reconnected socket with no snapshot is probed when the previous
snapshot listed `ping`;
  - a probe drop doubles the next deadline, and a pong resets it;
  - plus the earlier idle-ping, no-flag and `CLOSING` tests.
- The 10 new or changed tests fail with the previous `ws.ts` of this
branch (checked by restoring it).
- `scripts/terminal-input-regression.ts`: after the terminal becomes
ready with `a` held, Enter and `b` are typed: the draft reads `ab` and
nothing reaches the pane; Escape still reaches the pane with the draft
kept; Send types `ab` once with no Enter. It fails without the
PaneTerminal change (checked by restoring it and rebuilding).
- `server/ping.contract.test.ts` (integration, real herdr): the snapshot
lists `ping`; ping gets a pong with the same id, also for an observe
role; a malformed id gets `invalid_ping`; ping and pong pass through
`MachineRelay`. Not rerun for the review fixes (no server change).
- `bun run typecheck`: pass.
- `HERDR_TEST_MODE=unit bun test ./src/lib/ws.test.ts`: 49 pass.
- `bun run test:unit`: 3088 pass, 0 fail, 8 skip (includes the #949
convention guards).
- `bun run check run --build bun scripts/terminal-input-regression.ts`:
pass.
- `scripts/ui-contexts-regression.ts` passed on the first revision; not
rerun after the review fixes.
- No browser test of a real half-open socket: Playwright cannot hold a
socket half-open, so the unit tests cover that part.

## Notes
- **Stacked on #954; retarget to main after it merges.** It builds on
#954's connect deadline, `wake()` rules and `SOCKET_CLOSING` handling in
`ws.ts`.
- **Release:** `shared/protocol.ts` and `server/index.ts` are inputs to
the remote bundle, so the release must raise `REMOTE_BUNDLE_VERSION`.
The release guard from #948 (`scripts/release-guards.ts`) refuses to tag
otherwise. It is not bumped here. Until a remote PC updates, its
snapshot does not list `ping`, so it is never probed and behaves as
before.
- **Trade-off:** a key typed within one round trip of a probe start
lands in the held draft with Send/Discard rather than being sent, and
later typing joins it until the user picks one. Sending it later on its
own would break the never-queue rule.
- **Left out on purpose:**
- In-flight submits still settle as `disconnected` on a probe drop
rather than waiting for the close event: a half-open socket may not
close for minutes, and the server never types a message more than 45 s
after it arrived.
- No "checking connection" UI and no state-change event for probe start
and end. `inputReady` drives the "Waiting for terminal input…" banner
and the key bar, so a sub-second probe on a healthy link would flash
both on every wake. The held-draft banner already shows the case the
user can act on, and the composer no longer reports offline. The
remaining gap: PaneTerminal recomputes `inputReady` only when a message
arrives.
- The `ping` flag survives a reconnect: if a PC is downgraded to a
bridge without `ping`, the first probe after the reconnect costs one
drop before the new snapshot clears the flag.
- `resume()` in PaneTerminal still resets the screen before the attach
answers (render-scroll-1's second half).
- **Overlaps:**
- #956 edits the held-draft code (`applyToDraft` Backspace and line
breaks) and the same lines of `scripts/terminal-input-regression.ts`: a
textual conflict, and the test expectation changes with it: after #956
an Enter in the draft becomes a space plus a notice, so the held and
sent text become `a b` instead of `ab`. Never an Enter either way.
- #932 edits the same `submit()` guard area in `ws.ts`, plus
`protocol.ts` and the demo transport.
- #951, #936 and #859 add to the protocol unions, the demo switch and
`server/index.ts`'s message switch. These are textual merges with no
conflict in meaning.
- #962, #964 and #966 touch PaneTerminal's key bar and input line, not
the onData draft path.
  - #952 owns `server/machine-relay.ts`. Nothing changed there.
- From a mobile-terminal stability audit: connection-1, render-scroll-1,
lifecycle-1, history-1, moshi-1.

## Merge-session review
Fixed (each with a regression test that fails without the fix):
- **Hiding the page answered the probe** (`ws.ts`): a submit, pending
action or secret waiting on a probe was sent on an unchecked socket once
the page hid. A hidden page now pauses the probe (`probePaused`): input
stays refused, waiters keep waiting, and showing the page pings again.
Test: `keeps a submit and a secret waiting when the page hides during a
probe...`; the existing hide test now expects `probing` and refused
input while hidden.
- **Replacing a CLOSING socket left its probe unresolved** (`ws.ts`):
`connect()` now ends a non-null previous socket (CLOSING, or CLOSED
before its close event) first, so its probe and waiting submits settle,
the disconnect handlers run, and a late 4008 from it still stops output
(`endedEarly`). Test: `settles a submit waiting on a probe when a
CLOSING socket is replaced...`.
- **Ctrl+Enter under modifyOtherKeys got past the held-draft guard**
(`PaneTerminal.tsx`): a modified Enter is decided on before its rewrite
to `CSI 27;5;13~` and refused like a chord, with the held-input note.
Test: `terminal-input-regression` turns on modifyOtherKeys and presses
Ctrl+Enter beside a held draft.
- **A paste with a tab went to the pty and a multi-line paste was
dropped in silence** (`PaneTerminal.tsx`): while a draft is held, a
paste the draft cannot take is refused with the held-input note. Test:
paste `a\tb` and `x\ny` beside a held draft; nothing reaches the pane,
and the multi-line paste is told or held, never dropped in silence
(written so it still holds once #956 folds line breaks into the draft).
- **The input line said "disconnected" and its non-empty line got past
the draft rule** (`TerminalInput.tsx`, `PaneTerminal.tsx`): `onEnter`
returns `boolean | string` and the held-input reason is shown;
`sendTerminalLine` refuses beside a held draft too and keeps the text.
Test: empty Enter and `ls` + Enter on the input line beside a held draft
show the held-input note and send nothing.
- `src/AGENTS.md` rows and the changelog fragment updated to match.

Deferred (unchanged from the builder's notes, including its review
finding 8): the items under "Left out on purpose" above, such as
PaneTerminal recomputing `inputReady` only when a message arrives.

Notes for the merge step:
- #968 is stacked on this branch and does not carry these two commits;
it picks them up when restacked onto main after this PR squash-merges.
- #956 overlap: the `terminal-input-regression` Send step now checks the
draft's own text, so it holds whether #956 shows `ab` or `a b`.

Checks: `bun run check fast` pass; `scripts/ui-regression.ts` (terminal
input, Ctrl+Enter, held-draft lanes) pass;
`scripts/sticky-modifiers-regression.ts` pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
devswha added a commit that referenced this pull request Oct 13, 2026
Conflicts:
- src/components/TerminalInput.tsx: send() keeps reading the draft store
  and takes main's onEnter string result (#967 held-input reason).
- scripts/terminal-input-regression.ts: the PR's boolean socket refusal
  is renamed refuseConnections/currentSocket beside main's refuse()
  submit-result callback and toPage; the PR block reuses main's note
  locator.
- docs/terminal-input.md: main's key-bar paragraph (#962) plus the
  Send-during-composition sentences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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