383b9ae231
- The chat over a terminal pane now covers Claude, Codex, omp, Cursor and Antigravity: Cursor's agent transcript and agy's step log (brain/<id>/.system_generated/logs/transcript_full.jsonl) are read as conversations; Cursor sessions are claimed by their workspace - Replies appear as soon as they are written: the projects-tree watcher broadcasts `transcript_changed` (at most every 250 ms per session) and the chat re-reads the transcript while the terminal underneath draws - Stop shows from the pane's own busy line (Claude/Codex `esc to interrupt`, agy `… esc to cancel`), or fresh output for omp/Cursor — no hooks needed - Permission prompts read off the terminal become a card with the tool, its command and "Deny with a note"; Codex gets Permission mode / Model buttons that open its own numbered pickers, answered from the menu card - Artifacts: cards for pages/documents/diagrams/tables/React components, a Preview | Code panel with versions, copy, download, save to the Artifacts Hub, full screen; sandboxed iframes without allow-same-origin - Claude panes get CLAUDE_CODE_ENABLE_TODO_TOOLS=1 so the Progress panel has a plan; multi-question pane menus read their Next row and question tabs - The realtime chat bridge (structured stdio, /api/chat-bridge, /ws-chat) from the unreleased ad9d475 is not kept
444 lines
19 KiB
TypeScript
444 lines
19 KiB
TypeScript
/**
|
||
* @file panePaste.ts — lets another part of the Workspace page paste text into
|
||
* a workspace's terminal (a diff-line comment from the Changes panel). Every
|
||
* mounted TerminalView registers its run id; the text goes to the pane of that
|
||
* workspace focused last — clicked into, or made the Workspace's focused pane
|
||
* (a tab, Alt+1–4, the sidebar) — else any of its panes. It is a paste —
|
||
* bracketed, so `claude` takes it as one block in its composer — and never an
|
||
* Enter: the user reads it and sends it. Only a pane on SCREEN takes it (one
|
||
* hidden behind a full-width Changes panel or a replay would swallow it
|
||
* unseen); the caller says where it went, or that it went nowhere. The one
|
||
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
|
||
* what the user typed there, so it pastes AND presses Enter — and its Stop
|
||
* button presses Esc through `interruptRun`. Switching a pane between Chat and
|
||
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
|
||
* input box off the screen and empties it; the chat's draft is pasted back
|
||
* without Enter), so the half-typed text is never left behind on the other side.
|
||
* Attached images' paths go after the text, each in a paste of its own — how
|
||
* `codex` attaches one; a `claude` pane gets every file as an `@` mention in
|
||
* the text instead (`promptForPane`), since a pasted path stayed a plain
|
||
* `C:\…\drops\…png` there on Windows.
|
||
* The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
|
||
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
|
||
* arrows and Enter, or type into its "Type something" row).
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
/** The terminal's screen: one string per row, dim cells (a placeholder) left out. */
|
||
export type PaneScreen = { rows: string[]; cols: number };
|
||
|
||
interface Pane {
|
||
/** `files`: image paths pasted after `text`, one paste each. */
|
||
paste: (text: string, submit?: boolean, files?: string[]) => void;
|
||
visible: () => boolean;
|
||
key?: (data: string) => void;
|
||
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
|
||
screen?: (menuOnly?: boolean) => PaneScreen | null;
|
||
/** The rows on screen as plain text, dim cells kept. */
|
||
lines?: () => string[];
|
||
}
|
||
const panes = new Map<string, Pane>();
|
||
let lastFocused: string | null = null;
|
||
|
||
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
|
||
* raw input, as a key press; `screen`: its rows now; `lines`: the same as
|
||
* plain text); returns its unregister. */
|
||
export function registerPane(
|
||
runId: string,
|
||
paste: (text: string, submit?: boolean) => void,
|
||
visible: () => boolean = () => true,
|
||
key?: (data: string) => void,
|
||
screen?: (menuOnly?: boolean) => PaneScreen | null,
|
||
lines?: () => string[]
|
||
): () => void {
|
||
const entry = { paste, visible, key, screen, lines };
|
||
panes.set(runId, entry);
|
||
return () => {
|
||
if (panes.get(runId) === entry) panes.delete(runId);
|
||
if (lastFocused === runId) lastFocused = null;
|
||
};
|
||
}
|
||
|
||
export function notePaneFocus(runId: string): void {
|
||
lastFocused = runId;
|
||
}
|
||
|
||
/** Who wants to hear that a pane drew something (its chat: the CLI is doing
|
||
* something, so its transcript may have grown). */
|
||
const outputListeners = new Map<string, Set<() => void>>();
|
||
|
||
/** A terminal received output for `runId` (TerminalView, every PTY frame). */
|
||
export function notePaneOutput(runId: string): void {
|
||
outputListeners.get(runId)?.forEach((fn) => fn());
|
||
}
|
||
|
||
/** Calls `fn` on every output `runId`'s terminal receives; returns its unregister. */
|
||
export function onPaneOutput(runId: string, fn: () => void): () => void {
|
||
let set = outputListeners.get(runId);
|
||
if (!set) outputListeners.set(runId, (set = new Set()));
|
||
set.add(fn);
|
||
return () => {
|
||
set!.delete(fn);
|
||
if (!set!.size) outputListeners.delete(runId);
|
||
};
|
||
}
|
||
|
||
/** The tmux names of workspace `laneId`'s panes: `agenthub-lane-<id>[-slot]`. */
|
||
function ofLane(runId: string, laneId: number): boolean {
|
||
const base = `agenthub-lane-${laneId}`;
|
||
return runId === base || runId.startsWith(`${base}-`);
|
||
}
|
||
|
||
/** Pastes `text` into the pane of `runId` when it is mounted and on screen;
|
||
* false otherwise (not mounted yet, or hidden). */
|
||
export function pasteToRun(runId: string, text: string, files: string[] = []): boolean {
|
||
const p = panes.get(runId);
|
||
if (!p?.visible()) return false;
|
||
p.paste(text, false, files);
|
||
return true;
|
||
}
|
||
|
||
/** Sends `text` as a message to the pane of `runId`: a paste, then Enter.
|
||
* False when the pane is not mounted or not attached. */
|
||
export function submitToRun(runId: string, text: string, files: string[] = []): boolean {
|
||
const p = panes.get(runId);
|
||
if (!p?.visible()) return false;
|
||
p.paste(text, true, files);
|
||
return true;
|
||
}
|
||
|
||
/** Presses Esc in the pane of `runId` — how `claude` and `codex` stop the turn
|
||
* they are running. False when the pane is not mounted or not attached. */
|
||
export function interruptRun(runId: string): boolean {
|
||
const p = panes.get(runId);
|
||
if (!p?.visible() || !p.key) return false;
|
||
p.key("\x1b");
|
||
return true;
|
||
}
|
||
|
||
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
|
||
* last when it is — and returns its run id; null when none is on screen. */
|
||
export function pasteToLane(laneId: number, text: string): string | null {
|
||
const ok = (id: string) => ofLane(id, laneId) && !!panes.get(id)?.visible();
|
||
const id = lastFocused && ok(lastFocused) ? lastFocused : [...panes.keys()].find(ok);
|
||
if (!id) return null;
|
||
panes.get(id)!.paste(text);
|
||
return id;
|
||
}
|
||
|
||
const BORDER = /^─{3,}$/;
|
||
|
||
/** The text in a `claude` or `codex` input box on `rows`, or null when no box
|
||
* is shown (a menu, a permission prompt). Claude's box is the `❯` row under a
|
||
* `─` rule, up to the next rule; Codex's the last `›` row, up to the blank row
|
||
* above its footer. Both indent a continued row by two and wrap at word
|
||
* boundaries, so a row whose first word would have fitted on the one above
|
||
* starts a new line — a soft-wrapped row is joined back with its space. */
|
||
export function parsePromptInput(rows: string[], cols: number): string | null {
|
||
const r = rows.map((l) => l.trimEnd());
|
||
let p = -1;
|
||
let end = -1;
|
||
for (let i = r.length - 1; i >= 0 && p < 0; i--) {
|
||
const row = r[i] ?? "";
|
||
// Claude draws `❯` + a no-break space (U+00A0) in its box.
|
||
if (/^❯([ \u00a0]|$)/.test(row) && BORDER.test(r[i - 1]?.trim() ?? "")) {
|
||
p = i;
|
||
end = r.findIndex((l, j) => j > i && BORDER.test(l.trim()));
|
||
} else if (/^›( |$)/.test(row)) {
|
||
p = i;
|
||
let f = r.length - 1;
|
||
while (f > i && !r[f]) f--;
|
||
while (f > i && r[f]) f--;
|
||
end = f > i ? f : -1;
|
||
}
|
||
}
|
||
if (p < 0 || end < 0) return null;
|
||
const lines = r.slice(p, end).map((l) => l.slice(2));
|
||
while (lines.length > 1 && !lines[lines.length - 1]) lines.pop();
|
||
const width = cols - 2;
|
||
let out = "";
|
||
let prev: string | null = null;
|
||
for (const line of lines) {
|
||
const word = line.split(" ")[0] ?? "";
|
||
// The first row; a word longer than the row, broken mid-word; one that
|
||
// would not fit after the row above, wrapped; else a typed newline.
|
||
if (prev !== null)
|
||
out += prev.length >= width ? "" : word && prev.length + 1 + word.length > width ? " " : "\n";
|
||
out += line;
|
||
prev = line;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Moves the text out of `runId`'s input box: returns it and deletes it there
|
||
* (Delete then Backspace, as many as it has characters — never Ctrl+C or
|
||
* Esc, which stop a working turn). "" when no box can be read, it is empty,
|
||
* or it holds what the screen cannot show back (`[Pasted text #1]`,
|
||
* `[Image #1]`) — then it is left as it is. */
|
||
export function takeRunInput(runId: string): string {
|
||
const p = panes.get(runId);
|
||
const screen = p?.visible() && p.key ? p.screen?.() : null;
|
||
const text = screen ? parsePromptInput(screen.rows, screen.cols) : null;
|
||
if (!text?.trim() || /\[(Pasted text|Image) #\d+/.test(text)) return "";
|
||
const n = text.length + 1;
|
||
p!.key!("\x1b[3~".repeat(n) + "\x7f".repeat(n));
|
||
return text;
|
||
}
|
||
|
||
/** One choice of a menu the CLI shows: `n` its number (null for Claude's
|
||
* unnumbered Submit / Next row), `checked` a multiSelect box's state. */
|
||
export type PaneMenuOption = {
|
||
n: number | null;
|
||
label: string;
|
||
hint: string;
|
||
checked: boolean | null;
|
||
};
|
||
/** One question tab of an AskUserQuestion that asks several: `done` once it
|
||
* has an answer (`☒`). */
|
||
export type PaneMenuTab = { label: string; done: boolean };
|
||
/** A menu the CLI asks with (AskUserQuestion, a permission or trust prompt,
|
||
* Codex's approval): `cursor` the row its `❯`/`›` is on, `typeAt` the
|
||
* "Type something" row (-1: none). `tabs` only when it asks several
|
||
* questions (`← ☒ A □ B ✔ Submit →`), `tab` the one shown (-1: unknown).
|
||
* `detail` what a permission prompt asks about (its tool, then the command),
|
||
* when the screen shows it apart from the question. */
|
||
export type PaneMenu = {
|
||
question: string;
|
||
options: PaneMenuOption[];
|
||
cursor: number;
|
||
multi: boolean;
|
||
typeAt: number;
|
||
tabs?: PaneMenuTab[];
|
||
tab?: number;
|
||
detail?: string[];
|
||
};
|
||
|
||
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
|
||
/** A row only a menu shows: its cursor on a numbered option or Submit, or the
|
||
* "Enter to confirm" footer of Claude's unnumbered menus (the trust prompt). */
|
||
export const MENU_CURSOR_ROW =
|
||
/^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |(?:Submit|Next)\s*$)|Enter to confirm)/;
|
||
// The unnumbered button under a multiSelect's rows: Next while more questions
|
||
// follow, Submit on the last one.
|
||
const SUBMIT = /^ *([❯›])?[ \u00a0]*(Submit|Next)$/;
|
||
const TAB_BAR = /^ *← +(.+?) +→ *$/;
|
||
const RULE_ROW = /^ *[─╌]{3,} *$/;
|
||
const DASHED_ROW = /^ *╌{3,} *$/;
|
||
const CHECK = /^\[([ ✔])\] *(.*)$/;
|
||
const PLACEHOLDER = /^Type something\.?$/;
|
||
const COLUMN = /^(\S.*?\S) {2,}(\S.*)$/;
|
||
|
||
/** The numbered menu at the bottom of `rows`, or null. A menu has two
|
||
* numbered rows or more from 1, the cursor on one of them, and no rule right
|
||
* above its first row — a reply's numbered list has no cursor, and a list
|
||
* typed into Claude's input box sits under the box's rule. */
|
||
export function parsePaneMenu(rows: string[]): PaneMenu | null {
|
||
const r = rows.map((l) => l.trimEnd());
|
||
let end = r.length - 1;
|
||
while (end >= 0 && !r[end]) end--;
|
||
const options: PaneMenuOption[] = [];
|
||
let cursor = -1;
|
||
let hint: string[] = [];
|
||
let top = -1;
|
||
// Bottom up: a row under an option that is no option is its hint.
|
||
for (let i = end; i >= 0 && end - i < 40; i--) {
|
||
const row = r[i] ?? "";
|
||
const m = OPTION.exec(row);
|
||
const s = m ? null : SUBMIT.exec(row);
|
||
if (m || s) {
|
||
const text = m ? (m[3] ?? "") : (s?.[2] ?? "Submit");
|
||
const c = m ? CHECK.exec(text) : null;
|
||
// Codex's pickers (`/model`, `/permissions`) draw each row's
|
||
// description in a column after the name: split it off as the hint.
|
||
const col = COLUMN.exec(c ? (c[2] ?? "") : text);
|
||
const label = col ? col[1]! : c ? (c[2] ?? "") : text;
|
||
const h = [col?.[2], ...hint].filter(Boolean).join(" ");
|
||
options.unshift({
|
||
n: m ? Number(m[2]) : null,
|
||
label,
|
||
hint: h === label ? "" : h,
|
||
checked: c ? c[1] === "✔" : null,
|
||
});
|
||
if (m?.[1] || s?.[1]) cursor = options.length;
|
||
hint = [];
|
||
if (m?.[2] === "1") {
|
||
top = i;
|
||
break;
|
||
}
|
||
} else if (row && !RULE_ROW.test(row) && options.length) {
|
||
hint.unshift(row.trim());
|
||
} else if (row && !RULE_ROW.test(row)) {
|
||
hint = []; // the footer and anything under the last option
|
||
}
|
||
}
|
||
const numbered = options.filter((o) => o.n !== null);
|
||
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? ""))
|
||
return parseBareMenu(r.slice(0, end + 1));
|
||
if (numbered.some((o, i) => o.n !== i + 1)) return null;
|
||
// The question: the block of text right above the first option.
|
||
let q = top - 1;
|
||
while (q >= 0 && !r[q]) q--;
|
||
const lines: string[] = [];
|
||
for (; q >= 0 && r[q] && !RULE_ROW.test(r[q]!) && lines.length < 4; q--)
|
||
lines.unshift(r[q]!.trim());
|
||
const question = lines.join("\n");
|
||
const tabs = tabBar(r.slice(Math.max(0, q - 4), Math.max(0, q + 1)));
|
||
const low = question.toLowerCase();
|
||
// What a permission prompt asks about: Claude draws the tool and its
|
||
// command over a dashed rule above the question, up to the dialog's top rule.
|
||
const detail: string[] = [];
|
||
let d = q;
|
||
while (d >= 0 && !r[d]) d--;
|
||
if (d >= 0 && DASHED_ROW.test(r[d]!))
|
||
for (d--; d >= 0 && !RULE_ROW.test(r[d]!) && detail.length < 12; d--)
|
||
if (r[d]) detail.unshift(r[d]!.trim());
|
||
return {
|
||
question,
|
||
options,
|
||
cursor: options.length - cursor,
|
||
multi: options.some((o) => o.checked !== null),
|
||
typeAt: typeRow(options),
|
||
...(tabs.length > 1
|
||
? { tabs, tab: tabs.findIndex((t) => low.startsWith(t.label.toLowerCase())) }
|
||
: {}),
|
||
...(detail.length ? { detail } : {}),
|
||
};
|
||
}
|
||
|
||
/** The question tabs of a multi-question AskUserQuestion, read off its bar
|
||
* (`← ☒ Critical □ High 1/3 ✔ Submit →`) among `rows`; the Submit tab is
|
||
* no question. [] when there is no bar. */
|
||
function tabBar(rows: string[]): PaneMenuTab[] {
|
||
for (let i = rows.length - 1; i >= 0; i--) {
|
||
const m = TAB_BAR.exec(rows[i] ?? "");
|
||
if (!m) continue;
|
||
return (m[1] ?? "")
|
||
.split(/(?=[☒☐□✔☑■] )/)
|
||
.map((x) => x.trim())
|
||
.filter(Boolean)
|
||
.map((x) => ({ label: x.slice(1).trim(), done: /^[☒✔☑■]/.test(x) }))
|
||
.filter((t) => t.label && t.label !== "Submit");
|
||
}
|
||
return [];
|
||
}
|
||
|
||
const BARE_CURSOR = /^( {0,4})❯[ \u00a0]+(\S.*)$/;
|
||
|
||
/** Claude's UNNUMBERED menu at the bottom of `r` (the folder-trust prompt:
|
||
* `❯ No, exit` over ` Yes, I trust this folder`), or null. Its rows share the
|
||
* cursor row's text column, and nothing under them is a rule — Claude's input
|
||
* box draws one above and below its `❯` row, so neither the box nor a prompt
|
||
* in the history above it reads as a menu. Picked by arrows and Enter. */
|
||
function parseBareMenu(r: string[]): PaneMenu | null {
|
||
let at = -1;
|
||
for (let i = r.length - 1; i >= 0 && at < 0; i--) if (BARE_CURSOR.test(r[i] ?? "")) at = i;
|
||
if (at < 0 || RULE_ROW.test(r[at - 1] ?? "") || r.slice(at + 1).some((l) => RULE_ROW.test(l)))
|
||
return null;
|
||
const m = BARE_CURSOR.exec(r[at]!)!;
|
||
const indent = " ".repeat((m[1] ?? "").length + 2);
|
||
const sibling = (l = "") => l.startsWith(indent) && /\S/.test(l[indent.length] ?? " ");
|
||
let top = at;
|
||
while (top > 0 && sibling(r[top - 1])) top--;
|
||
let last = at;
|
||
while (last < r.length - 1 && sibling(r[last + 1])) last++;
|
||
if (last === top) return null;
|
||
const options = r.slice(top, last + 1).map((l, i) => ({
|
||
n: null,
|
||
label: top + i === at ? (m[2] ?? "").trim() : l.trim(),
|
||
hint: "",
|
||
checked: null,
|
||
}));
|
||
// The question: the paragraphs above, up to the dialog's top rule.
|
||
const lines: string[] = [];
|
||
for (let q = top - 1; q >= 0 && !RULE_ROW.test(r[q]!) && lines.length < 8; q--)
|
||
if (r[q]) lines.unshift(r[q]!.trim());
|
||
return { question: lines.join("\n"), options, cursor: at - top, multi: false, typeAt: -1 };
|
||
}
|
||
|
||
/** The "Type something" row. Text typed into it replaces its label, and it is
|
||
* still the last numbered row above "Chat about this". */
|
||
function typeRow(options: PaneMenuOption[]): number {
|
||
const at = options.findIndex((o) => PLACEHOLDER.test(o.label));
|
||
if (at >= 0) return at;
|
||
for (let i = options.findIndex((o) => o.label === "Chat about this") - 1; i >= 0; i--)
|
||
if (options[i]!.n !== null) return i;
|
||
return -1;
|
||
}
|
||
|
||
/** The status line a tool's TUI shows only while a turn runs, read off its
|
||
* binary's own strings: Claude's and Codex's `esc to interrupt`, agy's
|
||
* `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so
|
||
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. A
|
||
* tool missing here (omp, Cursor) has no line we know. */
|
||
export const BUSY_LINE: Readonly<Record<string, RegExp>> = {
|
||
claude: /\besc to interrupt\b/i,
|
||
codex: /\besc to interrupt\b/i,
|
||
antigravity: /…\s*\([^)]*\besc to cancel\)/i,
|
||
};
|
||
|
||
/** Whether `runId`'s pane shows `tool` at work now (its BUSY_LINE), or null
|
||
* when the tool's line is unknown or the pane is not mounted. */
|
||
export function readRunBusy(runId: string, tool: string): boolean | null {
|
||
const re = BUSY_LINE[tool];
|
||
const p = panes.get(runId);
|
||
if (!re || !p?.lines) return null;
|
||
return p.lines().some((l) => re.test(l));
|
||
}
|
||
|
||
/** The menu `runId`'s visible pane shows now, or null. */
|
||
export function readRunMenu(runId: string): PaneMenu | null {
|
||
const p = panes.get(runId);
|
||
// Polled every 700 ms while a chat shows: a screen with no cursor row is
|
||
// answered from plain text, without reading it cell by cell.
|
||
const screen = p?.visible() && p.key ? p.screen?.(true) : null;
|
||
return screen ? parsePaneMenu(screen.rows) : null;
|
||
}
|
||
|
||
// One key per write, a beat apart: a TUI reading two arrows in one chunk may
|
||
// take them as one key.
|
||
function pressKeys(key: (data: string) => void, keys: string[]) {
|
||
keys.forEach((k, i) => setTimeout(() => key(k), i * 40));
|
||
}
|
||
|
||
/** The arrow presses from the menu's cursor to row `to`. */
|
||
const walk = (menu: PaneMenu, to: number) =>
|
||
Array<string>(Math.abs(to - menu.cursor)).fill(to > menu.cursor ? "\x1b[B" : "\x1b[A");
|
||
|
||
/** Picks `menu`'s option `index` in `runId`'s pane: its digit — which picks
|
||
* it, or ticks its box in a multiSelect — else (Submit) the arrows to it
|
||
* and Enter. False when the pane is not visible or not attached. */
|
||
export function chooseRunOption(runId: string, menu: PaneMenu, index: number): boolean {
|
||
const p = panes.get(runId);
|
||
const opt = menu.options[index];
|
||
if (!p?.visible() || !p.key || !opt) return false;
|
||
if (opt.n !== null && opt.n < 10) p.key(String(opt.n));
|
||
else pressKeys(p.key, [...walk(menu, index), "\r"]);
|
||
return true;
|
||
}
|
||
|
||
/** Moves a multi-question AskUserQuestion to its previous (-1) or next (1)
|
||
* question tab, the arrows Claude's footer names. False when the pane is not
|
||
* visible or not attached. */
|
||
export function stepRunMenu(runId: string, dir: -1 | 1): boolean {
|
||
const p = panes.get(runId);
|
||
if (!p?.visible() || !p.key) return false;
|
||
p.key(dir < 0 ? "\x1b[D" : "\x1b[C");
|
||
return true;
|
||
}
|
||
|
||
/** Answers `menu` with typed text: the arrows to its "Type something" row,
|
||
* Ctrl+U over what is typed there already (one key clears it, however long
|
||
* it is), the text as a bracketed
|
||
* paste, and Enter — none in a multiSelect, where the text only ticks its
|
||
* row and Submit is still to pick. */
|
||
export function answerRunMenu(runId: string, menu: PaneMenu, text: string): boolean {
|
||
const p = panes.get(runId);
|
||
const row = menu.options[menu.typeAt];
|
||
if (!p?.visible() || !p.key || !row) return false;
|
||
const keys = walk(menu, menu.typeAt);
|
||
if (!PLACEHOLDER.test(row.label)) keys.push("\x15");
|
||
keys.push(`\x1b[200~${text}\x1b[201~`);
|
||
pressKeys(p.key, menu.multi ? keys : [...keys, "\r"]);
|
||
return true;
|
||
}
|