From 2edf5cd17f8d8e92bdc831a2b1aaa5496ba90992 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:09:00 +0800 Subject: [PATCH] fix(mobile): typing into a pane shows its cursor and what is being composed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping the terminal to type into it showed no cursor: xterm reports its cursor as shown and in place, but leaves it undrawn on the phone. And an input method's composing text — pinyin before a candidate is picked, dictation before it is done — lived only in the hidden field typing goes through, so nothing showed until it was committed, often only on Return. While the pane has the keys, the app draws the cursor itself, a blinking block at the pane's cursor cell, with the composing text underlined in front of it. A program that hides the terminal's cursor to draw its own (an agent's input box) gets only the composing text, where it lands. --- mobile/src/main.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++ mobile/src/style.css | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/mobile/src/main.ts b/mobile/src/main.ts index b8f8838c..6385aad2 100644 --- a/mobile/src/main.ts +++ b/mobile/src/main.ts @@ -3333,6 +3333,60 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, // The committed text is in the field after this event, not during it. setTimeout(flush); }); + // The pane's cursor while typing straight into it, and what an input + // method is still composing, drawn here over the terminal. xterm leaves + // its own cursor undrawn on the phone, and the composing text lives in + // a field no one sees: pinyin before a candidate is picked, dictation + // before it is done, showed nothing until it was committed. + let composing = ""; + const preedit = h("span", { class: "term-preedit" }); + const caret = h("div", { class: "term-caret", hidden: true, ariaHidden: "true" }, preedit, h("span", { class: "term-caret-block" })); + view.querySelector(".term-wrap")?.append(caret); + let caretFrame = 0; + const placeCaret = () => { + caretFrame = 0; + const buf = term.buffer.active; + const drawn = screenEl.querySelector(".xterm-screen"); + const row = buf.baseY + buf.cursorY - buf.viewportY; + // A program that draws its own cursor (an agent's input box) hides + // the terminal's; then only composing text is shown, where it lands. + const hidden = (term as unknown as { _core?: { coreService?: { isCursorHidden?: boolean } } })._core?.coreService?.isCursorHidden; + if (document.activeElement !== typing || !drawn || !caret.parentElement || row < 0 || row >= term.rows || (hidden && !composing)) { + caret.hidden = true; + return; + } + const box = drawn.getBoundingClientRect(); + const wrap = caret.parentElement.getBoundingClientRect(); + const cell = box.width / term.cols; + const rowH = box.height / term.rows; + caret.hidden = false; + caret.classList.toggle("bare", !!hidden); + caret.style.left = `${box.left - wrap.left + buf.cursorX * cell}px`; + caret.style.top = `${box.top - wrap.top + row * rowH}px`; + caret.style.height = `${rowH}px`; + caret.style.fontSize = `${term.options.fontSize}px`; + caret.style.setProperty("--cell", `${cell}px`); + preedit.textContent = composing; + }; + const caretSoon = () => { + if (!caretFrame) caretFrame = requestAnimationFrame(placeCaret); + }; + term.onRender(caretSoon); + term.onCursorMove(caretSoon); + screenEl.addEventListener("scroll", caretSoon, { passive: true }); + typing.addEventListener("focus", caretSoon); + typing.addEventListener("blur", () => { + composing = ""; + caretSoon(); + }); + typing.addEventListener("compositionupdate", (e) => { + composing = e.data ?? ""; + caretSoon(); + }); + typing.addEventListener("compositionend", () => { + composing = ""; + caretSoon(); + }); typing.addEventListener("input", (e) => { if (!(e as InputEvent).isComposing) flush(); }); diff --git a/mobile/src/style.css b/mobile/src/style.css index 58b7c525..e6f4456e 100644 --- a/mobile/src/style.css +++ b/mobile/src/style.css @@ -2837,3 +2837,50 @@ html[data-nav="pop"]::view-transition-new(root) { position: absolute; inset: -3px; } + +/* The cursor and an input method's composing text, over the pane while it + is typed into (main.ts `caret`). */ +.term-caret { + position: absolute; + z-index: 1; + display: flex; + align-items: stretch; + pointer-events: none; + font-family: var(--mono); + line-height: 1; +} + +.term-caret[hidden] { + display: none; +} + +.term-preedit { + display: flex; + align-items: center; + white-space: pre; + background: var(--term); + color: var(--ink); + text-decoration: underline; + text-underline-offset: 2px; +} + +.term-preedit:empty { + display: none; +} + +.term-caret-block { + width: var(--cell); + background: var(--ink); + opacity: 0.75; + animation: caret-blink 1.1s steps(1) infinite; +} + +.term-caret.bare .term-caret-block { + display: none; +} + +@keyframes caret-blink { + 50% { + opacity: 0.2; + } +}