mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
- 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
136 lines
4.2 KiB
TypeScript
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,
|
|
};
|
|
}
|