Files
DodoSSH/docs/adr/0015-android-terminal-input.md
T
jaap-janandClaude Opus 5 2810f7c12b
ci / build and test (push) Successful in 2m51s
ci / android head (push) Successful in 3m29s
ci / desktop nightly (push) Successful in 1m18s
ci / api image (push) Successful in 28s
Stop the terminal's Enter key dismissing the keyboard it was typed on
The first device run of ADR 0015 found the premise held and the field misconfigured
in a second way: the suggestion strip, autocorrect and predictive text were all
gone, and every Enter closed the keyboard. On a terminal that is every command, so
the phone asked for a tap between each one.

TerminalInputSurface had been left with Avalonia's default ReturnKeyType, and the
same OnCreateInputConnection that carries the inputType fix maps that default, on a
field that is not Multiline, to IME_ACTION_DONE:

    _ => options.Multiline ? 1073741824 : 6,   // 6 is IME_ACTION_DONE

Done means done. The keyboard performs the action and dismisses itself, which is
right for the text box the default was written for and wrong for a surface where
Enter is punctuation rather than submission.

TextInputReturnKeyType.Return is the member that maps to IME_ACTION_NONE — value 1,
so `returnKeyType - 1 => 0 => 1073741824` — leaving Enter a plain newline with no
action attached and no reason for the IME to hide.

◆ THE TRAP, WHICH IS THE ONE 11.10b ALREADY WARNED ABOUT FROM THE OTHER SIDE.

Multiline=true also reaches IME_ACTION_NONE, through that same default arm, and is
the obvious fix and the wrong one. It ORs TYPE_TEXT_FLAG_MULTI_LINE into InputType,
which moves it off 1 and silently fails the ShowSuggestions guard the whole ADR
turns on. Taking the obvious route to a working Enter would have handed the
suggestion strip back and looked like the premise had failed rather than like the
fix had. ReturnKeyType touches imeOptions only, which is why it is what is set.

Enter's byte was never the problem and is not what changed: IME_ACTION_DONE was
already synthesising KEYCODE_ENTER through AvaloniaInputConnection.PerformEditorAction,
so commands did run — the keyboard just left afterwards.

OnTextInput now also normalises line endings to CR. Belt and braces for the case
this change makes newly possible: a keyboard with no editor action attached may
commit "\n" as ordinary text rather than sending KEYCODE_ENTER, and a shell handed
LF for Enter looks like a shell that ignored the key. Normalising rather than
special-casing also stops a pasted "\r\n" running its line twice.

Check 11.10e is the regression test, and says in as many words not to fix a
recurrence with Multiline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:29:38 +02:00

15 KiB
Raw Blame History

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 §9, ADR 0011
  • Does not settle: whether the renderer stays xterm.js. That is Stage 3, 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:

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.
  • TextInputOptions.ReturnKeyType = Return, which is the fix for Enter — see the correction below.
  • 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.10e in docs/manual-checks.md, and 11.10b first of all, because it is the falsification test at the end of this document.

⚠️ Corrected on the first device run: every Enter closed the keyboard. The premise held — the suggestion strip, autocorrect and predictive text were all gone — but the field had been left with Avalonia's default ReturnKeyType, and the same OnCreateInputConnection maps that, on a field that is not Multiline, to IME_ACTION_DONE:

_ => options.Multiline ? 1073741824 : 6,   // 6 is IME_ACTION_DONE

Done means done: the keyboard performs the action and dismisses itself. On a text box that is right, and on a terminal it fires on every command. TextInputReturnKeyType.Return — value 1, so returnKeyType - 1 => 0 => 1073741824 — is IME_ACTION_NONE, which leaves Enter a plain newline with no action for the IME to perform and no reason for it to hide.

The trap worth recording, because it is the same trap 11.10b already warned about from the other side: Multiline = true also reaches IME_ACTION_NONE, through the default arm, and is the wrong way there. It ORs TYPE_TEXT_FLAG_MULTI_LINE into InputType, which moves it off 1 and silently fails the ShowSuggestions guard. Taking the obvious route to a working Enter would have handed the suggestion strip back and looked like the whole ADR had failed. ReturnKeyType touches imeOptions only.

Enter's byte was never in doubt and is not what changed — IME_ACTION_DONE was already synthesising KEYCODE_ENTER through AvaloniaInputConnection.PerformEditorAction, so commands ran; the keyboard just left afterwards. OnTextInput now also normalises line endings to CR, for the case where a keyboard with no editor action commits "\n" as ordinary text instead of sending a key event, and so that a pasted "\r\n" does not run its line twice.

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 MIT, headless, UI-agnostic; VT100/ANSI, dual buffers, 256/true colour, wide chars Evaluate emulation fidelity against xterm.js
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 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.