fix(mobile): typing into a pane shows its cursor and what is being composed

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.
This commit is contained in:
l0ng-ai
2026-10-03 23:09:00 +08:00
parent d3f7dadd5d
commit 2edf5cd17f
2 changed files with 101 additions and 0 deletions
+54
View File
@@ -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<HTMLElement>(".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();
});
+47
View File
@@ -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;
}
}