[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,589 @@
import type { PasswordPromptAssistMode } from "../../../domain/models";
const ESCAPE_SEQUENCE = "\\x" + "1b";
const BELL_SEQUENCE = "\\x" + "07";
const BRACKETED_PASTE_START = "\x1b[200~";
const BRACKETED_PASTE_END = "\x1b[201~";
const ANSI_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[[0-?]*[ -/]*[@-~]`, "g");
const OSC_PATTERN = new RegExp(
`${ESCAPE_SEQUENCE}\\][^${BELL_SEQUENCE}]*(?:${BELL_SEQUENCE}|${ESCAPE_SEQUENCE}\\\\)`,
"g",
);
// SGR conceal (parameter 8) hides the text it wraps. Refuse to treat concealed
// output as a real prompt so a remote can't disguise a fake prompt and trick the
// user into revealing the password.
const CONCEAL_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[(?:[0-9]+;)*8(?:;[0-9]+)*m`);
// A line that mentions password/密码/口令 and optionally ends in a colon.
// Intentionally broad: filling requires the user to confirm (press Enter), so
// over-matching only shows a dismissable hint and never leaks a password to a
// child program. The colon is optional because Kylin's sudo prompt doesn't
// use one (#1293).
const SUDO_PROMPT_PATTERN =
/(?:^|[\r\n])[^\r\n]*?(?:\bpassword\b|密\s*码|口\s*令)[^\r\n:]*(?:[:]\s*)?$/i;
// An explicit sudo prompt carries the sudo-specific "[sudo]" tag. No other tool
// prompts this way, so we hint on it WITHOUT requiring an arm — keeping the hint
// reliable even when command recording (arming) didn't fire for a manually
// typed command (#1284; manual typing's recordedCommand is flaky).
// Match [sudo] or [sudo: ...] variants (e.g. Chinese locale: [sudo: authenticate] 密码:, #1286).
// Colon is optional for Kylin (#1293).
const EXPLICIT_SUDO_PROMPT_PATTERN =
/(?:^|[\r\n])[^\r\n]*?\[sudo[^\]]*\][^\r\n]*?(?:\bpassword\b|密\s*码|口\s*令)[^\r\n:]*(?:[:]\s*)?$/i;
// Arm for direct sudo *and* su commands (#2156). Trailing space/end keeps
// `sum`/`suspend`/`suuser` out. `sudo` is checked before bare `su`.
const SUDO_COMMAND_PATTERN = /^\s*(?:builtin\s+|command\s+)?sudo(?:\s|$)/;
const SU_COMMAND_PATTERN = /^\s*(?:builtin\s+|command\s+)?su(?:\s|$)/;
const SUDO_OR_SU_COMMAND_PATTERN =
/^\s*(?:builtin\s+|command\s+)?su(?:do)?(?:\s|$)/;
// Used after confirm-to-fill: only re-open assist on a real auth retry, not on a
// subsequent child-program password prompt (e.g. `sudo mysql -p`).
const AUTH_RETRY_FAILURE_PATTERN =
/sorry,\s*try\s*again|incorrect\s+password|authentication\s+failure|auth(?:entication)?\s+fail|密码(?:错误|不正确)|认证失败|鉴权失败|口令错误/i;
// Sudo without the [sudo] tag (Kylin #1293) still scopes the prompt to the user
// ("password for alice", "输入密码"). Generic child-program prompts are excluded:
// "Enter password:", "Password for user postgres:", etc.
const SUDO_SCOPED_BARE_PROMPT_PATTERN =
/(?:password\s+for\b|的密码|输入密码|input\s+password)/i;
const CHILD_PROGRAM_PASSWORD_PROMPT_PATTERN =
/(?:enter\s+password\b|password\s+for\s+user\b)/i;
type ArmedCommandKind = "sudo" | "su";
export const stripTerminalControlSequences = (data: string): string =>
data.replace(OSC_PATTERN, "").replace(ANSI_PATTERN, "");
export const isSudoPasswordPrompt = (data: string): boolean => {
if (CONCEAL_PATTERN.test(data)) return false;
return SUDO_PROMPT_PATTERN.test(stripTerminalControlSequences(data));
};
export const isExplicitSudoPrompt = (data: string): boolean => {
if (CONCEAL_PATTERN.test(data)) return false;
return EXPLICIT_SUDO_PROMPT_PATTERN.test(stripTerminalControlSequences(data));
};
export const shouldArmSudoPasswordAutofill = (command: string): boolean =>
SUDO_OR_SU_COMMAND_PATTERN.test(command);
export const resolveArmedCommandKind = (command: string): ArmedCommandKind | null => {
if (SUDO_COMMAND_PATTERN.test(command)) return "sudo";
if (SU_COMMAND_PATTERN.test(command)) return "su";
return null;
};
/** Sudo prompts without [sudo] that still look like sudo/PAM, not mysql/psql. */
export const isSudoScopedBarePasswordPrompt = (data: string): boolean => {
if (CONCEAL_PATTERN.test(data)) return false;
const plain = stripTerminalControlSequences(data);
if (!isSudoPasswordPrompt(plain)) return false;
if (CHILD_PROGRAM_PASSWORD_PROMPT_PATTERN.test(plain)) return false;
return SUDO_SCOPED_BARE_PROMPT_PATTERN.test(plain);
};
/**
* su typically prints a short bare Password: / 密码: line.
* Reject SSH/scp style "user@host's password:" and long child prompts even
* while an su command arm is still active (e.g. passwordless su -c ssh).
*/
export const isSuBarePasswordPrompt = (data: string): boolean => {
if (CONCEAL_PATTERN.test(data)) return false;
const plain = stripTerminalControlSequences(data).replace(/\s+/g, " ").trim();
if (!plain) return false;
if (CHILD_PROGRAM_PASSWORD_PROMPT_PATTERN.test(plain)) return false;
// SSH/scp/rsync remote password prompts always include user@host.
if (plain.includes("@")) return false;
if (!isSudoPasswordPrompt(plain)) return false;
// Whole line should be essentially the password word (+ optional colon).
// Keep a small budget for locale variants like "Password: " / "密码:".
if (plain.length > 24) return false;
return /^(?:password|passwd|密\s*码|口\s*令)\s*[:]?\s*$/i.test(plain);
};
/** Public picker row — never includes the secret. */
export type PasswordPromptPickerItem = {
id: string;
label: string;
username?: string;
};
/** Internal candidate with password for confirm-to-fill. */
export type SudoPasswordAutofillCandidate = PasswordPromptPickerItem & {
password: string;
};
export type PasswordPromptPickerState = {
items: PasswordPromptPickerItem[];
selectedIndex: number;
};
export type SudoPasswordAutofill = {
armForCommand: (command: string) => void;
handleOutput: (data: string) => string;
/** Confirm with the selected (or host) password, or a specific candidate id. */
confirmFill: (candidateId?: string) => void;
/** Dismiss the open UI without clearing the su/sudo arm (Esc). */
cancelHint: () => void;
/**
* Hard-abort: hide UI and clear the arm (Ctrl+C / interrupt / disconnect).
* Unlike cancelHint, a later Password: line will not re-open assist until
* a fresh su/sudo command is armed (#2191).
*/
abort: () => void;
/**
* Soft-dismiss when the user pastes or types their own secret so Enter is
* not hijacked for confirmFill after clipboard paste (#2198).
* Returns true when the assist UI was dismissed.
*/
dismissOnUserContentInput: (data: string) => boolean;
isPromptPending: () => boolean;
/** True only while the multi-credential picker UI is open (not the hint). */
isPickerPending: () => boolean;
/**
* True when the user dismissed the UI with Esc but the command arm is still
* live and the last line still looks like a password prompt — Esc/↑/↓ can
* re-open the assist without re-running su/sudo.
*/
canReshowAssist: () => boolean;
/** Re-open assist after a soft dismiss. Returns whether the UI showed. */
tryReshowAssist: () => boolean;
/**
* Picker mode: move selection while the list is open.
* Returns true when the selection changed so callers can consume the key.
*/
moveSelection: (delta: number) => boolean;
updatePassword: (password?: string) => void;
updateCandidates: (candidates: SudoPasswordAutofillCandidate[]) => void;
updateMode: (mode: PasswordPromptAssistMode) => void;
};
const unwrapBracketedPaste = (data: string): string => {
if (data.startsWith(BRACKETED_PASTE_START) && data.endsWith(BRACKETED_PASTE_END)) {
return data.slice(BRACKETED_PASTE_START.length, -BRACKETED_PASTE_END.length);
}
return data;
};
export const getSinglePastedCommand = (
data: string,
): { command: string; lineEnding: string } | null => {
const match = unwrapBracketedPaste(data).match(/^([^\r\n]+)(\r\n|\r|\n)$/);
if (!match) return null;
return {
command: match[1],
lineEnding: match[2],
};
};
export const getSingleBracketedPasteLine = (data: string): string | null => {
if (!data.startsWith(BRACKETED_PASTE_START) || !data.endsWith(BRACKETED_PASTE_END)) {
return null;
}
const text = unwrapBracketedPaste(data);
if (!text || /[\r\n]/.test(text)) return null;
return text;
};
/**
* True when terminal input is the user supplying their own password text
* (typed char or clipboard paste), not Enter confirmation or Esc/Backspace.
*
* Used to dismiss password-prompt assist so a later Enter submits what the
* user pasted instead of hijacking Enter for the host session password
* (nested SSH / jump host, #2198).
*/
export const shouldDismissPasswordAssistOnInput = (data: string): boolean => {
if (!data) return false;
// Enter alone is handled by the key handler as confirmFill — do not dismiss.
if (data === "\r" || data === "\n" || data === "\r\n") return false;
// Bracketed paste always means the user is inserting their own text.
if (data.startsWith(BRACKETED_PASTE_START) || data.includes(BRACKETED_PASTE_START)) {
return true;
}
// Plain multi-char paste (no leading ESC / CSI).
if (data.length > 1 && !data.startsWith("\x1b")) {
return true;
}
// Single printable character — mirrors the key handler's cancelHint path.
// Exclude DEL (0x7f); Backspace/Esc are handled separately and must not
// count as "user password content" for onData-side dismissal.
const code = data.charCodeAt(0);
if (data.length === 1 && code >= 32 && code !== 0x7f) {
return true;
}
return false;
};
// Arm the autofill when a sudo/su command is submitted. The user's input is sent
// to the remote verbatim — we never rewrite it — so the terminal echo and cursor
// stay correct.
export const prepareSudoAutofillInput = (
data: string,
recordedCommand: string | null,
sudoAutofill: SudoPasswordAutofill | null | undefined,
): string => {
if (!sudoAutofill) return data;
if (data === "\r" || data === "\n") {
if (recordedCommand) sudoAutofill.armForCommand(recordedCommand);
return data;
}
if (data.startsWith(BRACKETED_PASTE_START) && data.endsWith(BRACKETED_PASTE_END)) {
return data;
}
const pastedCommand = getSinglePastedCommand(data);
if (pastedCommand) sudoAutofill.armForCommand(pastedCommand.command);
return data;
};
const toPickerItems = (
candidates: SudoPasswordAutofillCandidate[],
): PasswordPromptPickerItem[] =>
candidates.map(({ id, label, username }) => ({ id, label, username }));
// Confirm-to-fill model: when a sudo/su command is armed and a password prompt is
// seen, we DON'T send the password — we raise a hint or picker so the UI can
// offer confirmation. The password is only written when the user confirms via
// confirmFill(). This makes over-broad detection safe: a misfire just shows a
// dismissable UI instead of leaking the password.
export const createSudoPasswordAutofill = (_options: {
mode?: PasswordPromptAssistMode;
/** Hint-mode default password (host session password). */
password?: string;
/** Picker-mode candidates (host + keychain password identities). */
candidates?: SudoPasswordAutofillCandidate[];
write: (data: string) => void;
/** Show/hide the inline hint. Returns whether the hint actually rendered. */
onHint?: (active: boolean) => boolean;
/**
* Show/hide the credential picker. Returns whether the picker actually
* rendered. `state` is null when hiding.
*/
onPicker?: (active: boolean, state: PasswordPromptPickerState | null) => boolean;
now?: () => number;
}): SudoPasswordAutofill => {
const options = {
now: () => Date.now(),
onHint: (_active: boolean) => false,
onPicker: (_active: boolean, _state: PasswordPromptPickerState | null) => false,
..._options,
};
let mode: PasswordPromptAssistMode = options.mode ?? "hint";
let password = options.password ?? "";
let candidates: SudoPasswordAutofillCandidate[] = options.candidates ?? [];
const armWindowMs = 10_000;
let tail = "";
let armedUntil = Number.NEGATIVE_INFINITY;
let armedKind: ArmedCommandKind | null = null;
let pending = false;
let selectedIndex = 0;
let pendingUi: "hint" | "picker" | null = null;
/** True after confirmFill until we see success (non-prompt output) or expire. */
let postFillRetry = false;
/**
* User hit Esc while a prompt assist was open. Keep the arm so they can
* re-open (Esc/arrows) or so a real re-prompt can auto-show again — but do
* not immediately re-fire on the same static Password: line with no new
* output.
*/
let dismissedWhileArmed = false;
const hasFillMaterial = (): boolean => {
if (mode === "off") return false;
// Hint mode only uses the session host password — never an arbitrary
// keychain identity (that would silently send the wrong secret on Enter).
// Picker mode uses the full candidate list.
if (mode === "hint") return Boolean(password);
return candidates.length > 0 || Boolean(password);
};
/** Hint / single-password path: session password only (not candidates[0]). */
const defaultPassword = (): string => password || "";
const notifyPicker = (active: boolean): boolean => {
if (!active) {
return options.onPicker(false, null);
}
return options.onPicker(true, {
items: toPickerItems(candidates),
selectedIndex,
});
};
const hideUi = () => {
if (pendingUi === "hint") options.onHint(false);
if (pendingUi === "picker") options.onPicker(false, null);
pendingUi = null;
};
const isArmActiveNow = (): boolean =>
armedUntil !== Number.NEGATIVE_INFINITY && options.now() <= armedUntil;
const lastPromptLine = (): string => tail.split(/[\r\n]/).pop() ?? tail;
const disarm = () => {
armedUntil = Number.NEGATIVE_INFINITY;
armedKind = null;
postFillRetry = false;
dismissedWhileArmed = false;
tail = "";
selectedIndex = 0;
if (pending) {
pending = false;
hideUi();
}
};
const tryShowForCurrentTail = (): boolean => {
const armActive = isArmActiveNow();
const lastLine = lastPromptLine();
// Explicit [sudo] may show host-password hint without an arm; full picker
// still requires armed su (allowFullPickerForLine).
if (!isArmedPromptLine(lastLine, armActive)) return false;
const allowFullPicker = allowFullPickerForLine(lastLine, armActive);
if (!showAssist(allowFullPicker)) return false;
pending = true;
postFillRetry = false;
dismissedWhileArmed = false;
return true;
};
const isArmedPromptLine = (line: string, armActive: boolean): boolean => {
if (isExplicitSudoPrompt(line)) return true;
if (!armActive) return false;
// su always prompts with a bare Password: line (not Enter password / DB).
if (armedKind === "su") return isSuBarePasswordPrompt(line);
// sudo: only sudo-scoped prompts, never generic "Enter password:" from
// child programs when sudo credentials are already cached.
if (armedKind === "sudo") return isSudoScopedBarePasswordPrompt(line);
return false;
};
/**
* Full keychain picker is only for armed `su` prompts (#2156). Sudo keeps
* host-password quick-fill only — a multi-identity list after `sudo …` is
* too easy to confuse with a child-program password prompt (or a forged
* [sudo] line once auth is cached).
*/
const allowFullPickerForLine = (line: string, armActive: boolean): boolean => {
if (!armActive || armedKind !== "su") return false;
return isSuBarePasswordPrompt(line);
};
const showHostPasswordHint = (): boolean => {
if (!defaultPassword()) return false;
if (options.onHint(true)) {
pendingUi = "hint";
return true;
}
return false;
};
/**
* @param allowFullPicker When false (unarmed explicit [sudo] path), only
* the session host password may be offered — never the full keychain list.
* A forged remote `[sudo] password…` line must not surface other systems'
* secrets even though filling still requires a user click (#2156 review).
*/
const showAssist = (allowFullPicker: boolean): boolean => {
if (mode === "off" || !hasFillMaterial()) return false;
if (mode === "picker" && allowFullPicker && candidates.length > 0) {
selectedIndex = Math.min(selectedIndex, candidates.length - 1);
if (notifyPicker(true)) {
pendingUi = "picker";
return true;
}
return false;
}
// hint mode, or picker without arm / without multi candidates
return showHostPasswordHint();
};
/** Soft dismiss: hide UI but keep arm + tail so Esc/arrows can re-open. */
const softDismissPendingUi = () => {
if (!pending) return;
pending = false;
hideUi();
dismissedWhileArmed = isArmActiveNow();
if (!dismissedWhileArmed) {
armedKind = null;
armedUntil = Number.NEGATIVE_INFINITY;
}
};
return {
armForCommand: (command: string) => {
// Clear any prior arm/hint first: a non-sudo/su command must not leave a
// stale hint that a later prompt could satisfy.
disarm();
const kind = resolveArmedCommandKind(command);
if (!hasFillMaterial() || !kind) return;
armedKind = kind;
armedUntil = options.now() + armWindowMs;
tail = "";
},
handleOutput: (data: string) => {
if (!hasFillMaterial()) return data;
tail = `${tail}${data}`.slice(-1024);
// Fast path for bulk output: a prompt line ends in a colon, so a chunk
// with no colon can't be completing one. Skip the regex work unless a hint
// is pending (then we must keep watching for the prompt moving on).
// Also check for password keywords because Kylin's sudo prompt doesn't
// end with a colon (#1293).
if (
!pending &&
!data.includes(":") &&
!data.includes("") &&
!/(?:\bpassword\b|密码|口令)/i.test(data)
) {
return data;
}
const lastLine = lastPromptLine();
let armActive = isArmActiveNow();
if (!armActive) {
postFillRetry = false;
dismissedWhileArmed = false;
armedKind = null;
}
// Explicit "[sudo] …" always; su arm accepts bare Password:; sudo arm only
// accepts sudo-scoped bare prompts (not generic Enter password from mysql).
const isPrompt = isArmedPromptLine(lastLine, armActive);
if (pending) {
// The prompt moved on: a new line arrived and the latest line is no
// longer a password prompt (sudo timed out / failed / returned to the
// shell). Clear the pending UI — otherwise a later Enter would send
// the password to whatever is now reading input.
if (!isPrompt && /[\r\n]/.test(data)) disarm();
return data;
}
if (isPrompt) {
// Soft-dismissed (Esc): do not auto-reopen on the same static prompt
// with no new line. A real re-prompt (newline / auth-failure text)
// clears the dismiss flag and may show again.
if (dismissedWhileArmed) {
const looksLikeNewPromptCycle =
/[\r\n]/.test(data) || AUTH_RETRY_FAILURE_PATTERN.test(tail);
if (!looksLikeNewPromptCycle) return data;
dismissedWhileArmed = false;
}
// After a fill, only re-assist when this looks like a real auth retry
// (explicit [sudo] again, or failure text in the tail). A bare
// Password: from a child program after successful sudo must not reopen.
if (postFillRetry) {
const looksLikeAuthRetry =
isExplicitSudoPrompt(lastLine)
|| (armedKind === "su" && AUTH_RETRY_FAILURE_PATTERN.test(tail))
|| (armedKind === "sudo" && (
isExplicitSudoPrompt(lastLine) || AUTH_RETRY_FAILURE_PATTERN.test(tail)
));
if (!looksLikeAuthRetry) {
postFillRetry = false;
armedUntil = Number.NEGATIVE_INFINITY;
armedKind = null;
return data;
}
armActive = true;
}
// Full picker only with strong evidence the prompt is su/sudo itself.
// Unarmed / Kylin bare / ambiguous lines stay host-password hint only.
tryShowForCurrentTail();
}
return data;
},
confirmFill: (candidateId?: string) => {
if (!pending) return;
let secret = "";
if (candidateId) {
secret = candidates.find((c) => c.id === candidateId)?.password ?? "";
} else if (pendingUi === "picker" && candidates.length > 0) {
secret = candidates[selectedIndex]?.password ?? "";
} else {
// Hint path: only the explicit session password.
secret = defaultPassword();
}
if (!secret) {
disarm();
return;
}
options.write(`${secret}\n`);
// Clear pending UI. Keep a short arm + postFillRetry flag so a real
// sudo/su rejection re-prompt can reopen assist, but a later child
// Password: (e.g. after `sudo mysql -p`) will not (#2156 review).
pending = false;
hideUi();
dismissedWhileArmed = false;
postFillRetry = true;
if (hasFillMaterial()) {
armedUntil = options.now() + armWindowMs;
tail = "";
}
},
cancelHint: () => {
softDismissPendingUi();
},
dismissOnUserContentInput: (data: string) => {
if (!pending || !shouldDismissPasswordAssistOnInput(data)) return false;
softDismissPendingUi();
return true;
},
abort: () => {
disarm();
},
isPromptPending: () => pending,
isPickerPending: () => pending && pendingUi === "picker",
canReshowAssist: () => {
if (pending || !dismissedWhileArmed || !hasFillMaterial()) return false;
if (!isArmActiveNow()) return false;
return isArmedPromptLine(lastPromptLine(), true);
},
tryReshowAssist: () => {
if (pending || !hasFillMaterial()) return false;
if (!isArmActiveNow()) {
dismissedWhileArmed = false;
return false;
}
return tryShowForCurrentTail();
},
moveSelection: (delta: number) => {
if (!pending || pendingUi !== "picker" || candidates.length === 0) return false;
const next =
(selectedIndex + delta + candidates.length * 10) % candidates.length;
if (next === selectedIndex) return false;
selectedIndex = next;
notifyPicker(true);
return true;
},
updatePassword: (nextPassword?: string) => {
password = nextPassword ?? "";
if (!hasFillMaterial()) disarm();
},
updateCandidates: (next) => {
candidates = next ?? [];
if (selectedIndex >= candidates.length) {
selectedIndex = Math.max(0, candidates.length - 1);
}
if (!hasFillMaterial()) {
disarm();
return;
}
if (pending && pendingUi === "picker") {
notifyPicker(true);
}
},
updateMode: (nextMode) => {
mode = nextMode;
if (!hasFillMaterial()) {
disarm();
return;
}
// Mode change while pending: re-show the appropriate UI. Keep the full
// picker available only for armed su (same gate as first detection).
if (pending) {
const armStillActive =
armedUntil !== Number.NEGATIVE_INFINITY && options.now() <= armedUntil;
const allowFullPicker = armStillActive && armedKind === "su";
hideUi();
if (!showAssist(allowFullPicker)) {
pending = false;
}
}
},
};
};