Give the phone's terminal its own keyboard, and stop refereeing a focus fight
ci / build and test (push) Successful in 3m9s
ci / android head (push) Successful in 3m43s
ci / desktop nightly (push) Successful in 1m22s
ci / api image (push) Successful in 26s

Typing into a shell on the phone has been the worst thing about this head, and
the reason is not the renderer. It is that the keyboard over the terminal was
configured by Chromium rather than by us: a prose keyboard, with a suggestion
strip, autocorrect, autocapitalisation and a predictive layer that inserts text
ahead of the cursor — over a surface where every character is a command.

The obvious fix does not exist. xterm.js already sets every hint the web
platform has on its helper textarea — autocorrect, autocapitalize and
spellcheck are all off in the vendored bundle — and Android ignores all three,
because they are advisory. What actually silences a keyboard on this platform
is an inputType carrying TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, which is what
Termux's own TerminalView sets and the reason typing in Termux feels like a
terminal. A page inside a WebView cannot reach that field: Chromium derives the
whole EditorInfo from the focused DOM node, and no attribute produces the
visible-password variation.

Avalonia can reach it, on the version already pinned here. Decompiled from
Avalonia.Android 12.1.1, AndroidInputMethod.OnCreateInputConnection:

    if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
        outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);

0x80080 is VISIBLE_PASSWORD | NO_SUGGESTIONS — Termux's pair exactly. So the
keyboard behaviour wanted is one attached property away from any Avalonia
control, and permanently out of reach of the WebView. That asymmetry is the
whole decision; ADR 0015 records it, along with the two stages deliberately not
taken and what would falsify the premise.

◆ THE INPUT PATH LEAVES THE WEBVIEW. THE RENDERER DOES NOT MOVE.

TerminalInputSurface is a 1x1 focusable control that draws nothing and is where
every character typed into a shell now arrives. It is not stretched over the
renderer, which would be the obvious shape and is wrong three times over: the
WebView composites above everything Avalonia paints so it would be invisible,
it would never see the touches the WebView consumes first, and it would sit
over the connect page sharing that Panel whenever no session is open. Focus
does not need area.

Its IME client retains nothing and claims nothing — no surrounding text, no
preedit — because a terminal has no document for an input method to edit. The
line being edited is on the far side of an SSH channel and this client has
never been told what is on it.

◆ THE FOCUS FIGHT ENDS BY REMOVING A CONTESTANT.

TerminalFocus is deleted rather than amended. It existed to hand Android's own
view focus back to the WebView after AvaloniaView.DispatchTouchEvent took it at
both ends of every touch, and it was correct for as long as the input
connection belonged to the page. It no longer does, so the focus the platform
hands to Avalonia's view is now the focus the keyboard wants. The accessory
row's two posted counter-moves go with it; the keys stay Focusable=false, which
is now what keeps Avalonia's focus on the input surface.

RendererFocus replaces it with the opposite move: the WebView is made
non-focusable, so it cannot take an input connection and Chromium's own
showSoftInput cannot succeed from it. Non-focusable is not non-interactive —
focusability governs keyboard input, not touch delivery — so the page keeps its
pan-y scrollback untouched. What it can no longer do is raise a keyboard, so
taps on it are observed with Handled=false throughout and reported, filtered by
the platform's own ScaledTouchSlop. Without that filter, dragging the
scrollback would raise the keyboard over the output being scrolled to.

◆ TWO THINGS FOUND ON THE WAY, ONE OF THEM OLD.

The Ctrl latch never worked with the software keyboard. Not a regression and
not new — everything typed went straight into the page and never passed
SendAsync, which is where the latch lives, so `ctrl` then `c` was inert unless
the second key also came from the accessory row. Routing input through Avalonia
fixes it as a side effect, and check 11.10d exists to keep it fixed.

Avalonia raises a text-input event for any key whose UnicodeChar is 32 or
above, whether or not the key event was handled — it reads the handled flag
only to fold into its own return value. Ctrl-C on a hardware keyboard therefore
produced 0x03 and then a literal c. controlByteAlreadySent swallows the second.
The keys in TryTranslate need no such guard, their UnicodeChars being below 32.

One deliberate loss, in the open: Delete sends backspace. Avalonia turns an
IME's deleteSurroundingText into a dispatched KEYCODE_FORWARD_DEL, so a soft
keyboard taking that route is indistinguishable here from a real Delete key —
and a backspace that deletes forwards on every phone is worse than a missing
forward-delete on the few devices that have one.

Nothing here is verifiable without a device, so the checks are written rather
than the tests: 11.10b is the falsification test and should be run first,
because everything staged on this stands or falls on whether one attached
property actually silences a real keyboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jaap-janandClaude Opus 5 committed 2026-08-25 10:12:14 +02:00
1 parent c0396b4f4d
commit c0dd5a147e
9 files changed
+904 -119

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
# ADR 0015 — The Android terminal's input path leaves the WebView
- Status: proposed — **Stage 1 built, unverified on a device**
- Date: 2026-08-25
- Builds on: [`docs/android-port.md`](../android-port.md) §9, [ADR 0011](0011-android-distribution.md)
- Does not settle: whether the renderer stays xterm.js. That is [Stage 3](#stage-3--the-renderer-deliberately-unsettled), and it is deliberately left open.
## Context
The phone got the desktop's terminal, and the reasons were good ones. `TerminalDataPlane` already
served the page and a binary protocol over a loopback WebSocket, the xterm.js bundles were embedded
resources and platform-neutral, and `Avalonia.Controls.WebView` shipped a `net10.0-android36.0`
target. `docs/android-port.md` called it *"better news than expected"*, and for rendering it was.
**For typing it was not, and typing is the complaint.** Not scrollback, not colour, not throughput —
the act of putting characters into a shell. That narrows the problem usefully, because the input path
and the render path are separable, and only one of them is wrong.
### Three things sit between a finger and a byte, and the middle one is the fault
**1. Avalonia and the WebView fight over native focus.** `AvaloniaView.DispatchTouchEvent` ends every
handled touch — DOWN and UP alike — with a `RequestFocus()` for Avalonia's own view. The WebView's
input connection dies with its focus, so the keyboard swaps layouts and the inset churn can park it
over the accessory row that was tapped. `Platform/TerminalFocus.cs` exists to hand focus back, posted
to the next main-looper message so the platform has taken its turn first. It works. It is also a
counter-move in a fight that only exists because the thing holding the input connection is not the
thing the framework thinks is focused.
**2. The keyboard is a prose keyboard, and no DOM attribute makes it stop.** This is the one that was
worth checking rather than assuming, and the check inverted the obvious conclusion.
The vendored xterm.js *already* sets every hint the web platform has on its helper textarea —
`autocapitalize="off"`, `autocorrect="off"`, `spellcheck="false"`. They are set, and Gboard shows the
suggestion strip and predictive text anyway. So this is not a missing attribute; it is a ceiling.
Termux's `TerminalView.onCreateInputConnection` says what the ceiling is. It sets:
```
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
```
**`VISIBLE_PASSWORD` is the load-bearing half.** `NO_SUGGESTIONS` alone is advisory and most keyboards
ignore it — a point made in Avalonia's own Android keyboard PR discussion, not only in Termux's. What
actually turns off autocorrect, the suggestion strip and the predictive composing layer is the
*variation*: a field the keyboard believes is a visible password gets no dictionary. That is an
`EditorInfo.inputType` on a native `InputConnection`, and a page inside a WebView has no way to reach
it — Chromium computes the `EditorInfo` from the focused DOM node, and no combination of attributes on
a `<textarea>` produces the visible-password variation.
**3. The accessory row is a workaround for a keyboard, not a keyboard.** Esc, Tab, Ctrl and the arrows
are right and stay. But every tap on one costs the focus round-trip in (1), and none of them can be
held or repeated.
### The finding that decides this ADR
Avalonia already emits Termux's exact `inputType`. Decompiled from the pinned
`Avalonia.Android` 12.1.1, `AndroidInputMethod<TView>.OnCreateInputConnection`:
```csharp
if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
{
outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
}
```
`0x80080` is `TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | TYPE_TEXT_FLAG_NO_SUGGESTIONS`. The guard reads:
`ContentType` left at `Normal`, `Multiline` false, `AutoCapitalization` false, and
`TextInputOptions.ShowSuggestions` explicitly `false` — a `bool?` whose default is `null`, so it must
be set, not merely left alone. The same method also ORs `IME_FLAG_NO_EXTRACT_UI |
IME_FLAG_NO_FULLSCREEN` into `imeOptions`, which is the other thing Termux sets and for the same
reason: a terminal must never be replaced by a full-screen extract editor in landscape.
So the keyboard behaviour that makes Termux feel like a terminal is **already available on the
version this repository pins**, to any Avalonia control that asks for it. It is not available to the
WebView, and never will be. That asymmetry is the whole decision.
## Decision
### Avalonia owns the keystrokes. The WebView renders and nothing else.
A focusable Avalonia control — `TerminalInputSurface` — becomes the terminal's input target. It draws
nothing. It sits in the same `Panel` as the renderer, takes focus when the terminal surface is shown,
and keeps it for as long as that surface is up.
- `TextInputOptions.ShowSuggestions = false`, with `ContentType`, `Multiline` and `AutoCapitalization`
left at the defaults the guard above requires. This is the entire fix for the suggestion strip.
- It implements `TextInputMethodClient` with an empty surrounding text and a zero-length selection.
A terminal has no document for an IME to edit; committed text is forwarded and nothing is retained.
- Committed text and key events are encoded to bytes and written to the session through the same path
`TerminalScreen`'s accessory keys already use. No new transport, no change to `TerminalDataPlane`.
- The native WebView is made non-focusable (`Focusable = false`, `FocusableInTouchMode = false`) once,
at attach.
**`Platform/TerminalFocus.cs` is deleted by this, not amended.** The fight it referees ends when only
one side is a focus candidate. Its remarks should move into the new control as the record of why the
WebView is deliberately unfocusable, because that reasoning outlives the workaround.
`Platform/SoftKeyboard.cs` stays. Dismissing a keyboard on the way to a sheet is still a platform
call, and Avalonia still offers no way to close one.
### Not: patching xterm.js's textarea
Already tried by xterm.js itself, and already failing — see Context (2). The attributes are set. The
platform ignores them. There is no version of this that reaches `VISIBLE_PASSWORD` from inside a
WebView, so the effort buys nothing.
### Not: a native Android `View`, Termux-shaped, today
Termux's `TerminalView` + `TerminalRenderer` drawing to a `Canvas` is the right architecture for a
phone-only terminal, and DodoSSH is not a phone-only terminal. Writing one now means two terminal
implementations — this one and the desktop's — with two escape-sequence behaviours to keep honest
against each other, for a defect that is entirely in the input path. That is a large bill for the
wrong problem.
It also does not become unavailable later. Stage 3 keeps it on the table.
### Not: replacing xterm.js now
Rendering is not the complaint. xterm.js with the WebGL addon draws a correct, fast grid, and
`terminal.js` already handles renderer death, reattach and refit — hard-won behaviour recorded in
`docs/android-port.md` that a rewrite would have to re-earn. Replacing a working renderer to fix a
keyboard is the wrong trade, and doing it in the same change as the input move would make neither
half attributable when the result is judged on a device.
## Staging
### Stage 1 — the input surface · **built**
The decision above, and nothing else. This is the change that answers the complaint, and it should be
judged on a device on its own before anything else is built on it.
What landed:
| | |
| --- | --- |
| `Views/TerminalInputSurface.cs` | The 1×1 focusable control, `ShowSuggestions = false`, the IME client that retains nothing, and the key→escape-sequence table |
| `Views/TerminalInputEventArgs.cs` | One keystroke's bytes, on the way to `TerminalScreen.SendAsync` |
| `Platform/RendererFocus.cs` | Makes the `WebView` non-focusable and reports taps on it |
| `Platform/TerminalFocus.cs` | **Deleted.** The fight it refereed has one contestant now |
| `Views/TerminalScreen.axaml{,.cs}` | Hosts the surface, raises the keyboard on arrival, drops the two posted focus counter-moves from the accessory row |
**One thing gained that was not asked for, and it was a real defect:** the Ctrl latch now reaches the
software keyboard. It never did before — everything typed went straight into the page and never passed
`SendAsync`, which is where the latch lives — so `ctrl` then `c` was inert unless the second key also came
from the accessory row. Check 11.10d covers it.
**Verify before building anything on this:** checks 11.10a–11.10d in `docs/manual-checks.md`, and 11.10b
first of all, because it is the falsification test at the end of this document.
### Stage 2 — the keys around it
Only once Stage 1 is proven, because each of these is cheap *given* Avalonia owning input and awkward
without it:
- **Hold-to-repeat** on the arrows and Backspace. Currently impossible without a focus round-trip per
tap.
- **Ctrl held rather than latched**, with latching kept as the fallback. `TerminalScreen`'s remark is
right that latching is what phone SSH clients do; held becomes possible when one control sees both
the modifier press and the following keystroke.
- **Pinch and volume keys for font size**, replacing the pinned `A−`/`A+` buttons. Both are what
JuiceSSH does and what its users name first; the buttons stay as the discoverable path.
Volume-key capture is an `onKeyDown` on the activity and is deliberately opt-in — a phone that cannot
change its volume with a shell open is a worse phone.
### Stage 3 — the renderer, deliberately unsettled
If, after Stages 1 and 2, the terminal still reads as a web page — touch selection, scrollback
survival across renderer death, scroll feel — then the renderer is the next question, and the
candidates are known:
| Option | What it is | Cost |
| --- | --- | --- |
| [XTerm.NET](https://github.com/tomlm/XTerm.NET) | MIT, headless, UI-agnostic; VT100/ANSI, dual buffers, 256/true colour, wide chars | Evaluate emulation fidelity against xterm.js |
| [VtNetCore](https://github.com/darrenstarr/VtNetCore) | MIT, .NET Standard 2.0, older but proven | Maintenance status |
| Port xterm.js's core | Exact behavioural parity with the desktop | Largest, and a permanent fork |
Rendered by a custom Avalonia `Control` via `DrawGlyphRun`, which is Termux's architecture expressed
in Avalonia and would work on the desktop too — eventually letting the WebView2 dependency go, and
with it the `targetSdk` 36 floor `Avalonia.Controls.WebView` currently forces.
**Stage 1 is not wasted if Stage 3 happens.** The input surface is exactly the layer such a control
would need, unchanged. That is the reason for splitting them.
## Consequences
**What gets better, and it is one thing done properly.** No suggestion strip, no autocorrect, no
predictive composing layer, no full-screen extract editor, and no focus flap on an accessory key.
That is the difference between typing at a shell and fighting a phone.
**CJK and other composing input is degraded, and this is a real cost.** `VISIBLE_PASSWORD` suppresses
composition, which is precisely what makes it work for a terminal and precisely what a Chinese,
Japanese or Korean user needs. Termux has the same limitation and lives with it. If it ever needs
answering, the answer is a per-session toggle that drops `ShowSuggestions = false` and accepts the
prose keyboard back — not a middle setting, because there isn't one.
**Text selection and copy are unchanged**, still xterm.js's own DOM handling, still without touch
selection handles — [xterm.js #5377](https://github.com/xtermjs/xterm.js/issues/5377) is open and
unassigned and this ADR does not fix it. Stage 3 territory.
**Scrollback still dies with the renderer process.** Unchanged, still honest about it via the replay
banner, still Stage 3 territory.
**Nothing here is verifiable headless**, for the reason `docs/manual-checks.md` §11 already gives.
Stage 1 needs new manual checks — at minimum: the suggestion strip is absent with a shell open; an
accessory key does not disturb the keyboard (§11.10a, which should now pass trivially rather than by
posted counter-move); landscape does not produce a full-screen extract editor; and a hardware
keyboard still reaches the shell.
**The hardware-keyboard path moves.** It currently arrives through the WebView; after this it arrives
through Avalonia. Behaviour should be identical and probably will not be on the first try.
## What would falsify this
If, on a device, `ShowSuggestions = false` does not suppress the suggestion strip — because the guard
was not satisfied, or the keyboard ignores the variation too — then Stage 1's premise is wrong and
the honest next step is Stage 3 with a native `InputConnection`, not more Avalonia. **Check this
first, on the smallest possible spike, before building `TerminalInputSurface` properly.** It is one
attached property on a focusable control and a look at the keyboard.
+6
View File
@@ -269,6 +269,12 @@ accessory key row for this. That is UI work, not porting.
> reconnected; earlier output stayed on the host"* — is that honesty put where the person looking at the
> terminal will actually read it, not buried in a log.
> **⚠️ Under review, on the input half only.** The renderer's story above stands; what has not held up in
> use is *typing*. A software keyboard over a WebView cannot be told to stop autocorrecting — the DOM
> attributes xterm.js already sets are advisory and Android ignores them — and the accessory row pays a
> focus round-trip per tap. [ADR 0015](adr/0015-android-terminal-input.md) proposes moving the input path to
> Avalonia and leaving the renderer exactly as this section describes it.
---
## Decisions taken
+69 -16
View File
@@ -1840,12 +1840,22 @@ Now tap **Tab** on the accessory row, or **A+**, and type again.
keyboard.
**Failure means:** the buttons on that row have become focusable again. An ordinary Avalonia button takes
focus on tap, which takes it off the `NativeWebView` — and the package's own `OnLostFocus` then resigns the
page's focus. The row goes on working, because its keys are pressed rather than typed into, so the symptom
is a terminal that answers the buttons and ignores the keyboard: it reads as the session having died. See
`Focusable = false` in `TerminalScreen.axaml.cs` and the focus entry in `docs/platform-flags.md`.
focus on tap, which takes it off `TerminalInputSurface`. The row goes on working, because its keys are
pressed rather than typed into, so the symptom is a terminal that answers the buttons and ignores the
keyboard: it reads as the session having died. See `Focusable = false` in `TerminalScreen.axaml.cs`.
Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first
**Also worth typing while a hardware keyboard is attached**, because that path moved under
[ADR 0015](adr/0015-android-terminal-input.md) and is the one most likely to have been broken by the move:
it used to arrive through the `WebView`'s own key handling and now arrives through Avalonia's. Confirm that
Ctrl-C interrupts **and leaves no stray `c` at the prompt** — Avalonia raises the letter's own text event
whether or not the key event was handled, so a doubled character here means `controlByteAlreadySent` is not
doing its job — that the arrow keys reach shell history, that Backspace deletes one character, and that Tab
completes a path. **Forward-delete is a known, deliberate loss** — the Delete key sends a backspace,
because Avalonia turns an IME's `deleteSurroundingText` into a `KEYCODE_FORWARD_DEL` and a broken backspace
on every phone is worse than a missing forward-delete on the few devices with the key. See
`TerminalInputSurface.TryTranslate`.
Worth doing on the software keyboard too, where the old fault showed as the keyboard closing on the first
tap of an arrow key.
### 11.10a The accessory keys do not cost the terminal its *software* keyboard either
@@ -1853,18 +1863,61 @@ tap of an arrow key.
With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then
keep typing on the software keyboard.
**Pass:** the keyboard settles back unchanged — same layout, same suggestion strip, same height — and
everything typed after the tap still reaches the terminal. The accessory row stays visible above the
keyboard throughout. A blink during the press itself is tolerable: the platform takes the focus on both
halves of every touch and the return is posted right behind each theft, so the connection can visibly flap
for the press's own duration — what it must never do is *stay* swapped after the finger lifts.
**Pass:** the keyboard settles back unchanged — same layout, same height — and everything typed after the
tap still reaches the terminal. The accessory row stays visible above the keyboard throughout. There should
be **no blink at all** now: under [ADR 0015](adr/0015-android-terminal-input.md) the input target is an
Avalonia control, so the focus the platform hands to Avalonia's own view after every dispatched touch is
already the focus the keyboard wants, and there is nothing to hand back.
**Failure means:** Android's own view focus stayed on Avalonia's input view after the tap instead of being
handed back. This is the half `Focusable = false` cannot reach — the platform requests focus for its own
view after dispatching every handled touch — and the symptom chain is the keyboard swapping to its no-input
layout and the inset churn parking it over the very row that was tapped. The first fix for this failed by
timing alone: it handed focus back from inside the very dispatch the platform re-steals it after. See
`TerminalFocus` in the Android head's Platform folder for both the mechanism and the fix's shape.
**Failure means:** the buttons on that row have become focusable again, taking Avalonia's focus off
`TerminalInputSurface`. The old failure — a keyboard that swapped layouts and parked itself over the row —
belonged to the arrangement where the `WebView` held the input connection and is not reachable from here;
if it reappears, the renderer has taken focus back and `RendererFocus.Suppress` did not run. See check
11.10c.
### 11.10b The keyboard over a terminal is a terminal's keyboard · **the important one for ADR 0015**
Open a shell and look at the keyboard, without typing anything yet. Then type a few words of ordinary
English — `ls`, `cd etc`, `sudo apt update`.
**Pass:** there is **no suggestion strip** above the keys, nothing is autocapitalised at the start of a line,
no word is autocorrected, and no predictive text appears ahead of the cursor. Backspace deletes exactly one
character per press. Rotating to landscape does not replace the terminal with a full-screen text editor.
**Failure means:** the premise of ADR 0015 is wrong and everything staged on it needs rethinking, so find
out *which* half failed before concluding anything. Either `TextInputOptions.ShowSuggestions` is not
reaching Avalonia's `AndroidInputMethod` — the guard there is narrow and needs `InputType` to be exactly
`TYPE_CLASS_TEXT`, so anything that sets `ContentType`, `Multiline` or `AutoCapitalization` on
`TerminalInputSurface` or an ancestor silently disables it, `inherits: true` being the trap — or the
keyboard is ignoring `TYPE_TEXT_VARIATION_VISIBLE_PASSWORD`, which would mean no Avalonia-side fix exists
and the honest next step is a native input connection. Worth repeating on a second keyboard (Gboard and one
other) before deciding, because this is a claim about keyboards rather than about Android.
### 11.10c The renderer cannot take the keyboard, and a tap still brings it back
With a shell open, put the keyboard away with the system back gesture. Then tap the terminal. Separately:
with the keyboard up, drag the terminal to scroll its scrollback.
**Pass:** the tap brings the keyboard straight back. The drag scrolls the scrollback and does **not** raise
the keyboard. At no point does a text caret or a selection handle appear inside the terminal.
**Failure means:** if the tap does nothing, `RendererFocus.Suppress` never found the `WebView` — it is
called from two places, the posted `EnvironmentRequested` handler and the `IsTerminalShowing` transition,
and a miss in both leaves no tap listener. If the drag raises the keyboard, the touch-slop test is not
working. If a caret appears, the `WebView` is still focusable and the page has taken the input connection
back, which is the whole failure ADR 0015 exists to prevent.
### 11.10d The Ctrl latch reaches the software keyboard
With a shell open and something running that can be interrupted — `sleep 60`, or `top` — tap **ctrl** on the
accessory row and then press **c** on the software keyboard.
**Pass:** the process is interrupted, and the ctrl key returns to its unlatched colour.
**Failure means:** typed characters are not going through `TerminalScreen.SendAsync`. Note that this check
could not pass *before* ADR 0015 and is not a regression test but a new capability: the latch used to apply
only to the accessory row's own keys, because everything typed went straight into the page and never passed
the code that holds the latch.
### 11.11 Closing a connection and opening a new one both take you somewhere real