hermes-agent/web/src/lib/pty-mobile-input.ts
ethernet 02613a4d50 fix(web): resolve all eslint errors, downgrade react-hooks v7 to warnings
- Fix no-useless-escape in i18n/ko.ts and i18n/uk.ts (remove backslash
  escapes inside single-quoted strings)
- Add eslint-disable-next-line for no-control-regex in pty-mobile-input.ts
  (terminal data legitimately contains control characters)
- Configure react-refresh/only-export-components with allowConstantExport
  so context providers that export hooks don't trigger the rule
- Downgrade react-hooks v7 rules (set-state-in-effect, refs,
  preserve-manual-memoization, static-components) from error to warn —
  these are real concerns but the existing code uses common patterns
  (data loading on mount, ref-as-instance-var) that need careful refactoring
2026-07-16 01:42:02 +05:30

136 lines
4.2 KiB
TypeScript

const DELETE = "\x7f";
// How long (ms) after a mobile IME / replacement event we treat subsequent
// terminal input as a candidate line-replacement rather than a plain append.
// Exported so the ChatPage integration and tests share one tunable value.
export const MOBILE_REPLACEMENT_WINDOW_MS = 350;
function chars(text: string): string[] {
return Array.from(text);
}
function removeLastChar(text: string): string {
const c = chars(text);
c.pop();
return c.join("");
}
function isPlainText(data: string): boolean {
// eslint-disable-next-line no-control-regex -- terminal data may contain control chars
return !/[\x00-\x1f\x7f]/.test(data);
}
function lastWordMatch(line: string): RegExpMatchArray | null {
return line.match(/^(.*?)(\S+)(\s*)$/u);
}
function collapseDuplicatedFinalWord(text: string, previousLine: string): string {
const match = text.match(/^(.*?)(\S+)(\s+)(\S+)(\s*)$/u);
if (!match) return text;
const [, prefix, first, , second, trailing] = match;
if (first.toLocaleLowerCase() !== second.toLocaleLowerCase()) return text;
// Only collapse a duplication the tracked line already ended with — i.e.
// Gboard re-emitted the final word. Requiring a >=2-char word avoids
// eating legitimate single-letter reduplication ("a a", "i i") that a
// user may genuinely type inside the replacement window.
if (first.length < 2) return text;
if (!previousLine.trimEnd().toLocaleLowerCase().endsWith(first.toLocaleLowerCase())) {
return text;
}
return `${prefix}${first}${trailing}`;
}
function replacementLineForMobileInput(
currentLine: string,
incoming: string,
): string | null {
if (!currentLine || currentLine.length < 2 || !incoming) return null;
const currentLower = currentLine.toLocaleLowerCase();
const incomingLower = incoming.toLocaleLowerCase();
if (incomingLower.startsWith(currentLower)) {
return collapseDuplicatedFinalWord(incoming, currentLine);
}
const word = lastWordMatch(currentLine);
if (!word) return null;
const [, prefix, last, trailing] = word;
if (trailing) return null;
const incomingFirst = incoming.trimStart().split(/\s+/u)[0] ?? "";
if (
incomingFirst &&
incomingFirst.toLocaleLowerCase() === last.toLocaleLowerCase()
) {
return `${prefix}${collapseDuplicatedFinalWord(incoming, currentLine)}`;
}
return null;
}
export function shouldTreatInputAsMobileReplacement(
inputType: string | undefined,
data: string | null | undefined,
isMobileLike: boolean,
): boolean {
if (
inputType === "insertReplacementText" ||
inputType === "insertFromComposition" ||
inputType === "insertCompositionText"
) {
return true;
}
return isMobileLike && inputType === "insertText" && (data?.length ?? 0) > 1;
}
export function updatePtyInputLine(currentLine: string, data: string): string {
// Escape sequences (arrow keys, home/end, function keys, paste guards)
// move the cursor or edit the line in ways this flat tracker cannot
// model — and the per-char loop below would append their printable
// payload (e.g. the "[D" of a left-arrow) as if it were typed text.
// Reset instead: an unknown cursor position must disarm replacement
// normalization until the user starts a fresh, cleanly-tracked line.
if (data.includes("\x1b")) {
return "";
}
let next = currentLine;
for (const ch of chars(data)) {
if (ch === "\r" || ch === "\n") {
next = "";
} else if (ch === DELETE || ch === "\b") {
next = removeLastChar(next);
} else if (ch === "\x15") {
next = "";
} else if (isPlainText(ch)) {
next += ch;
}
}
return next;
}
export function normalizePtyMobileInput(
data: string,
currentLine: string,
replacementActive: boolean,
): { data: string; nextLine: string; normalized: boolean } {
if (replacementActive && isPlainText(data)) {
const replacementLine = replacementLineForMobileInput(currentLine, data);
if (replacementLine !== null) {
return {
data: DELETE.repeat(chars(currentLine).length) + replacementLine,
nextLine: replacementLine,
normalized: true,
};
}
}
return {
data,
nextLine: updatePtyInputLine(currentLine, data),
normalized: false,
};
}