Skip to content

feat(terminal): add a phone keyboard visibility control - #1973

Merged
devswha merged 1 commit into
mainfrom
feat/terminal-keyboard-toggle
Oct 26, 2026
Merged

devswha merged 1 commit into
mainfrom
feat/terminal-keyboard-toggle

Conversation

@devswha

@devswha devswha commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

Closes #1046. Related: #1635 (not implemented here, see below).

What

On a touch screen the terminal's key bar gets a Show keyboard / Hide keyboard control ([data-key="keyboard"]), after the Input line switch and Paste. It moves the soft keyboard without changing the input mode:

  • Show focuses the field of the current input mode inside the tap, so a phone raises its keyboard: the input line, or xterm's own textarea in direct typing. The bar is not shown while the masked input is.
  • Hide blurs this pane's focused field only; a sibling pane, or the control itself focused from a hardware keyboard, is left alone.
  • Its name, glyph (PanelBottomOpen / PanelBottomClose) and aria-pressed follow the keyboard state lib/viewport.ts measured (data-keyboard), never the focus. A hardware keyboard, or an occlusion the browser cannot report, reads as down.
  • It sends no key or input, changes no setting or mode, and fits or resizes nothing (the grid under a keyboard stays, Terminal (phone): opening the soft keyboard resizes the shared pty #1045). While an application has focus reporting on (DECSET 1004), xterm reports its textarea's focus and blur as ESC [ I / ESC [ O. The control holds a flag around its own focus() / blur(), and onData drops exactly those two reports while the flag is held. A focus the user makes still reports.
  • While a word is being composed in either field, a direct commit is still pending, or Paste is still reading the clipboard, the control is aria-disabled and refuses the tap in place. That keeps the composition, and keeps a paste's own focus from bringing a hidden keyboard back. View only has no control. Held, ended and offline panes show a disabled one.
  • Safari's Hangul and dictation preedit has no composition events and waits for the next key, paste or blur (the xterm patch from fix(terminal): type Safari dictation once and keep refused IME text out of the pane #1850). Hide's blur types it, the same way a key-bar key types it first. Refusing instead would leave Hide refused after every word typed that way.
  • Unlike the keys, it is a Tab stop with its localized name as a tooltip. Disabled keys draw in --text-dim, and the bar's focus ring is drawn inside the key so the scroller no longer clips it.

Design reference: the platform's own dismiss key (iPadOS's keyboard key, Android's ▽). It lives in the key bar with the mode switch, because this app keeps its touch controls in the bar and its menus. That is where it differs from agent-of-empires' floating KeyboardFab. It also follows the measured keyboard rather than input focus, as the issue asks. DESIGN.md has the entry. No code was copied.

Not in this PR: the rest of #1046

#1046 also proposes an optional arrow-joystick key and light haptic feedback. Neither is here. The maintainer's status on #1046 named the keyboard key as the work left to ship. If the joystick should stay tracked, change Closes to Refs, or keep the item on #1053.

Not in this PR: #1635

#1635 proposes an opt-in "keyboard only when I ask" model: a setting that sets inputmode="none" on every terminal field while the keyboard is hidden, with the mode switch moved to a long press. That is a separate, larger design, and the control here does not do it. Taps on the grid, the line and the key bar behave as they do on main. This control would be the natural toggle for that model if it lands.

Tests and checks

  • src/lib/keyboard.test.ts: keyboardToggleAction and subscribeKeyboardUp.

  • scripts/terminal-keyboard-demo-regression.ts (browser lane), on the real client over the demo transport as a phone. It checks:

    • focus of the current mode's field, the measured state, and the draft and caret;
    • that a composition refuses the tap;
    • that a sibling pane is never touched;
    • view only, held, offline and ended panes;
    • no focus reports with DECSET 1004 on, while the user's own focus still reports;
    • that no input, keys, resize or take-over frame is sent.

    It also writes a 390 / 1440, light / dark, en / ko capture set.

  • Determinism (the open item on Terminal (phone): a keyboard show/hide button, and an optional arrow joystick key #1046): the captures used to wait for the demo's one-shot finish droplet to attach. The page's clock runs on its own while the page and its fonts load, so a slow load let the droplet expire before that wait, and the run hung. The captures now run the clock past the completion and do not draw the droplet (as sidebar-filter-demo-regression.ts does), so no step waits on a transient.

  • scripts/key-bar-customization-demo-regression.ts: an empty key list keeps the fixed controls direct, paste, keyboard.

Reviews

  • omo-native-code-reviewer: APPROVE, no blockers. Fixed: Hide during a pending Paste was undone by the paste's own focus (now refused), DESIGN.md wording about the fixed controls and Paste's note, and the masked-input branch of Show, which the bar could never reach (removed).
  • Codex (gpt-6.1-sol, xhigh): three Medium findings.
    • The i18n entries belonged in the terminal dictionary part. Fixed.
    • Safari's replacement preedit is not refused. Kept on purpose, see above.
    • Show in one split pane makes a focused sibling pane report its blur (ESC [ O). Kept: the sibling really loses the focus, and a tap into this pane's line does the same. Refusing Show there would leave the selected pane without its keyboard. DESIGN.md now says this.

Local checks

  • bun run check fast passed on the pushed commit: workflow syntax, generated types and themes, typecheck, build and unit tests.
  • terminal-keyboard-demo-regression.ts passed 3 runs in a row (2 before the review fixes, 1 on the final commit). keyboard-viewport-demo-regression.ts and key-bar-customization-demo-regression.ts passed.
  • 390 and 1440 captures checked by eye: the order is Input line, Paste, keyboard control; the glyph and pressed state follow the measured keyboard; the desktop has no key bar and is unchanged.

On a touch screen the terminal's key bar gets a Show keyboard / Hide keyboard control after the
Input line switch and Paste (#1046). Show focuses the field of the current input mode inside the tap
(the input line, else xterm's textarea); Hide blurs only this pane's field. The pressed state and the name follow data-keyboard as lib/viewport.ts measured it,
never the focus, so a hardware keyboard reads as down. The control sends no key, changes no setting
or input mode and fits or resizes nothing; while a word is composed in either field, a direct
commit is pending or Paste still reads the clipboard (its own focus would bring a hidden keyboard
back), it is aria-disabled and refuses the tap without deferring anything. Safari's Hangul or
dictation preedit, which has no composition events, is typed by Hide's blur as a key-bar key types
it first. Without a
field it may focus (held, ended, offline) it is disabled, and an observer has none.

xterm reports its textarea's focus and blur as input while an application has focus reporting on
(DECSET 1004), so the control holds a flag around its own focus() and blur(): onData drops exactly
those two reports, and reassertGrid leaves the grid alone. A focus the user makes still reports.
Unlike the keys, the control is a Tab stop with its localized name as a tooltip. Disabled keys draw
in --text-dim, and the bar's focus ring is drawn inside the key so the scroller no longer clips it.
It stays in the bar rather than AoE's floating button; DESIGN.md says why.

Adds keyboardToggleAction and subscribeKeyboardUp to lib/keyboard.ts with unit tests, ko/ja/zh
entries in the terminal dictionary part, a changelog fragment, and scripts/terminal-keyboard-demo-regression.ts in the browser lane.
Its captures run the clock past the demo's one-shot turn completion and do not draw the droplet it
raises, instead of waiting for that droplet: the clock runs while the page and its fonts load, so a
slow load let the droplet leave before the wait began.

Refs #1046
@coderabbitai

coderabbitai Bot commented Oct 26, 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 8 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
Commits

Reviewing files that changed from the base of the PR and between 7ab4bde and 5d19f31.

Files selected for processing: 16
  • DESIGN.md
  • changelog.d/terminal-keyboard-toggle.md
  • docs/development.md
  • scripts/ci-browser.sh
  • scripts/key-bar-customization-demo-regression.ts
  • scripts/terminal-keyboard-demo-regression.ts
  • src/components/KeyBar.css
  • src/components/KeyBar.tsx
  • src/components/PaneTerminal.tsx
  • src/lib/i18n/terminal.ja.ts
  • src/lib/i18n/terminal.ko.ts
  • src/lib/i18n/terminal.zh.ts
  • src/lib/keyboard.test.ts
  • src/lib/keyboard.ts
  • src/lib/useTerminalMount.ts
  • src/lib/useTerminalSends.ts
  • 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.

@devswha

devswha commented Oct 26, 2026

Copy link
Copy Markdown
Owner Author

Merge pass review report. Overall: PASSED. This is a client-only change in the terminal input path. The Astra gate (omo-native-gate-reviewer, gpt-6-astra) approved 5d19f31.

Tests:

  • scripts/terminal-keyboard-demo-regression.ts and scripts/key-bar-customization-demo-regression.ts pass, as do the 19 keyboard unit tests.
  • The keyboard regression fails against the parent build, which lacks the feature.

Independent probes:

  • Space activates the control.
  • The control refuses a tap while a paste is still reading the clipboard.
  • The flag that suppresses the focus report is cleared even after an exception.
  • A later user focus or blur is still reported (DECSET 1004).

Screenshots: phone and desktop captures were inspected. The control appears only on touch screens.

Bridge traps: the control sends no input apart from the focus reports a user's own focus would send. It resizes, takes over and refocuses nothing, and acts on no sibling pane.

Not covered:

  • Native iOS and Android keyboards, and Hangul or dictation preedit, were not reproduced.
  • While a direct commit is pending, the control refuses taps but does not always keep aria-disabled. Active composition and paste-read cases do keep it.
  • CodeRabbit's success was a rate-limited run, so CodeRabbit did not actually review this PR.

git merge-tree against main is clean. The only files shared with other maintainer PRs are DESIGN.md (#1969, #1975) and scripts/ci-browser.sh (#1975), and they merge with those PRs without conflict.

@devswha
devswha merged commit e76b9f8 into main Oct 26, 2026
12 checks passed
@devswha
devswha deleted the feat/terminal-keyboard-toggle branch October 26, 2026 09:36
@devswha devswha mentioned this pull request Oct 27, 2026
2 of 14 tasks
devswha added a commit that referenced this pull request Oct 29, 2026
…2333)

Closes the phone keyboard-control bug that #1973's control hid
underneath: the control is there, but a tap's meaning could not be
trusted.

### Problem

On a phone, the key bar over the keyboard has an Input line switch,
Paste and a **Show keyboard / Hide keyboard** control (#1046, #1973).
Two things went wrong with it:

- The keyboard control fights itself. Tapping Hide makes the keyboard
open; the open control shows armed over a keyboard that is visibly up;
and tapping several times in a row makes the keyboard open and close in
bursts, the label and the glyph flipping without ever matching the
keyboard for long.
- After Android's back gesture (the keyboard put away, the field left
focused), tapping the control does nothing.

Switching Input line / direct typing rides the same keyboard: the switch
alone raises it and puts it away, so changing where you type looks like
a broken keyboard button.

### Cause

The control had no state of its own. Its label, glyph, `aria-pressed`
and the action the tap took were each re-read, at tap time, from
`data-keyboard` — a visual-viewport measurement that flips in the middle
of the keyboard's own slide (Chromium's geometry overshoots the resting
height and no API says when it settled). A tap therefore meant whichever
side of the animation the last measurement happened to land on, and a
measurement blind to a zoomed page or a floating keyboard kept the
control lying for good.

### Change

- New `src/lib/keyboardControl.ts`: a small store that keeps the **tap's
word** (last tap wins) until the keyboard measurement confirms it and
holds still (120 ms, past the overshoot) or a 700 ms settle window runs
out. A composition or a pending commit refuses the tap before any state
changes, and a tap this pane has no target for records nothing. One
derivation (`view`) is what the label, the glyph, `aria-pressed` and the
next tap's action all read, so the control can never say one thing and
do another.
- `lib/viewport.ts` feeds each measurement to the store;
`data-keyboard`'s meaning for every other reader (shell height, grid
hold, CSS) is untouched.
- `toggleKeyboard` now only performs the focus and blur, inside the
tap's own gesture: Show blur-and-refocuses an already focused field,
which is what re-raises the keyboard a back gesture put away, and Hide's
blur leaving no field focused anywhere reads as the keyboard going where
the measurement is blind.
- The Input line switch is no keyboard gesture: direct to line hands the
grid's keyboard to the input line, focus and all, in a layout effect
before paint, gated on touch.

### What it improves

- Hide puts the keyboard away the first time, every time, and the
control always says what its next tap will do — the armed state can no
longer disagree with the keyboard.
- Rapid taps end in the last tap's state instead of an open/close storm.
- The keyboard comes back after the back gesture.
- Switching input modes keeps the keyboard up where you had it, instead
of raising and dismissing it by side effect.

---------

Co-authored-by: devswha <devswha@gmail.com>
devswha added a commit that referenced this pull request Nov 5, 2026
…-in) (#2966)

Fixes #1635.

## What

A new opt-in setting, **Settings > Terminal > Keyboard only from the key
bar** (off by default, a device setting kept in its own storage key like
the other recent opt-ins). With it on, on a touch screen:

- While this pane's key-bar keyboard control reads **Show keyboard**,
the input line and xterm's helper textarea carry `inputmode="none"`. A
tap on the grid, the line, Send or a key-bar key still focuses them as
before, but the browser raises no soft keyboard.
- **Show keyboard** removes the attribute from the field it focuses,
inside the same tap, so the keyboard comes up.
- **Hide keyboard**, or the system putting the keyboard away (Android's
back gesture, read from the measured viewport), turns the control back
to Show, and the attribute comes back.

With it off (the default), nothing changes: no field gets `inputmode`,
except the existing `none` on xterm's field in Input line mode.

The hold is `setting && coarse pointer && !requested`. `requested` is a
per-pane state that the control's Show tap sets. Hide clears it, and so
do a keyboard measured going down (the back gesture, through
`lib/keyboardControl.ts`'s notifications) and the focus leaving the
pane's fields. Nothing is held while the control cannot be pressed
(offline, input not ready, held by another bridge, ended, view only).
The line still takes a draft there, so a tap raises the keyboard as with
the setting off (omo re-review). The focus-out check accepts only the
line or xterm's field, not other fields in the pane such as the find
bar. The hold is deliberately not the control's displayed direction:
that falls back to the viewport measurement after its settle window, and
the measurement is blind to an iPad floating keyboard or a zoomed page
(Codex review). The line gets it as a prop. xterm's field gets it
through its own layout effect in `useTerminalSends`, separate from the
mode-switch effect, so a re-hold never re-runs that effect's blur or
handoff.

## Differences from the issue's proposal

- The issue proposed a setting that is on by default and is switched off
to opt in. This one is named for the opt-in instead and is off by
default. The behaviour is the same.
- The Input line / Direct typing switch does not move to a long press.
Since #1973 it has its own key beside the keyboard control, so no long
press is needed.

**Design reference:** Termux's KEYBOARD extra key, the keyboard toggle
of a phone terminal app. Reading is the rest state, and typing is asked
for. DESIGN.md has the entry.

## Tests

- `scripts/terminal-keyboard-demo-regression.ts`: a new
`keyboard-on-request` scenario on a 390-wide phone runs with the setting
off and on, in line and direct mode. At rest, after a tap on the line or
grid, after Show (`inputmode` checked as the tap's own focus runs), with
the keyboard up, after the system dismissal, after Hide, after an
unmeasured Show outlives the settle window, and after the focus leaves
the pane's fields, it checks the field's `inputmode` and focus. An
offline step (the line still raises a keyboard for a draft) was added in
the second round. Each review step fails without its fix; the unmeasured
step fails with (`an unmeasured keyboard Show raised is not taken
back`). **Failing first:** with the `src/` change stashed, it fails at
`on, line: at rest the field raises no keyboard` (actual `null`,
expected `"none"`). With the change, the whole script passes, including
every existing scenario.
- `src/lib/settings.test.ts`: the default, sanitising, device scope, and
the setting's own key surviving an old tab's whole-record save.

## Screenshots

Settings > Terminal at 390 and 1280, in light and dark, were checked by
eye with the row off and on. The row sits right after Key bar and uses
the same Toggle as its neighbours. Nothing else changes visually.

## Validation

- `bun run check fast`: pass (on the merged head, via the heavy runner).
- `bun scripts/terminal-keyboard-demo-regression.ts`: pass.
- `bun test src/lib/settings.test.ts`: pass.
- Not run: the full browser suite (CI runs it), and a native iOS or
Android keyboard check. Chromium's synthetic viewport stands in for the
keyboard here; `inputmode="none"` is the platforms' documented way to
suppress the soft keyboard (Chrome on Android, Safari on iOS).
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.

Terminal (phone): a keyboard show/hide button, and an optional arrow joystick key

1 participant