feat: add tmux-style process key helpers
This commit is contained in:
@@ -21,6 +21,7 @@
|
|||||||
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
|
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
|
||||||
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
|
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
|
||||||
- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`.
|
- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`.
|
||||||
|
- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
|
||||||
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
|
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
|
||||||
- Directory: unify `clawdbot directory` across channels and plugin channels.
|
- Directory: unify `clawdbot directory` across channels and plugin channels.
|
||||||
- UI: allow deleting sessions from the Control UI.
|
- UI: allow deleting sessions from the Control UI.
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ Background + poll:
|
|||||||
{"tool":"process","action":"poll","sessionId":"<id>"}
|
{"tool":"process","action":"poll","sessionId":"<id>"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Send keys (tmux-style):
|
||||||
|
```json
|
||||||
|
{"tool":"process","action":"send-keys","sessionId":"<id>","keys":["Enter"]}
|
||||||
|
{"tool":"process","action":"send-keys","sessionId":"<id>","keys":["C-c"]}
|
||||||
|
{"tool":"process","action":"send-keys","sessionId":"<id>","keys":["Up","Up","Enter"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste (bracketed by default):
|
||||||
|
```json
|
||||||
|
{"tool":"process","action":"paste","sessionId":"<id>","text":"line1\nline2\n"}
|
||||||
|
```
|
||||||
|
|
||||||
## apply_patch (experimental)
|
## apply_patch (experimental)
|
||||||
|
|
||||||
`apply_patch` is a subtool of `exec` for structured multi-file edits.
|
`apply_patch` is a subtool of `exec` for structured multi-file edits.
|
||||||
|
|||||||
45
src/agents/bash-tools.process.send-keys.test.ts
Normal file
45
src/agents/bash-tools.process.send-keys.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { afterEach, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { resetProcessRegistryForTests } from "./bash-process-registry";
|
||||||
|
import { createExecTool } from "./bash-tools.exec";
|
||||||
|
import { createProcessTool } from "./bash-tools.process";
|
||||||
|
|
||||||
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetProcessRegistryForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("process send-keys encodes Enter for pty sessions", async () => {
|
||||||
|
const execTool = createExecTool();
|
||||||
|
const processTool = createProcessTool();
|
||||||
|
const result = await execTool.execute("toolcall", {
|
||||||
|
command:
|
||||||
|
"node -e \"process.stdin.on('data', d => { process.stdout.write(d); if (d.includes(13)) process.exit(0); });\"",
|
||||||
|
pty: true,
|
||||||
|
background: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.details.status).toBe("running");
|
||||||
|
const sessionId = result.details.sessionId;
|
||||||
|
expect(sessionId).toBeTruthy();
|
||||||
|
|
||||||
|
await processTool.execute("toolcall", {
|
||||||
|
action: "send-keys",
|
||||||
|
sessionId,
|
||||||
|
keys: ["h", "i", "Enter"],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
await wait(50);
|
||||||
|
const poll = await processTool.execute("toolcall", { action: "poll", sessionId });
|
||||||
|
const details = poll.details as { status?: string; aggregated?: string };
|
||||||
|
if (details.status !== "running") {
|
||||||
|
expect(details.status).toBe("completed");
|
||||||
|
expect(details.aggregated ?? "").toContain("hi");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("PTY session did not exit after send-keys");
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
sliceLogLines,
|
sliceLogLines,
|
||||||
truncateMiddle,
|
truncateMiddle,
|
||||||
} from "./bash-tools.shared.js";
|
} from "./bash-tools.shared.js";
|
||||||
|
import { encodeKeySequence, encodePaste } from "./pty-keys.js";
|
||||||
|
|
||||||
export type ProcessToolDefaults = {
|
export type ProcessToolDefaults = {
|
||||||
cleanupMs?: number;
|
cleanupMs?: number;
|
||||||
@@ -29,6 +30,15 @@ const processSchema = Type.Object({
|
|||||||
action: Type.String({ description: "Process action" }),
|
action: Type.String({ description: "Process action" }),
|
||||||
sessionId: Type.Optional(Type.String({ description: "Session id for actions other than list" })),
|
sessionId: Type.Optional(Type.String({ description: "Session id for actions other than list" })),
|
||||||
data: Type.Optional(Type.String({ description: "Data to write for write" })),
|
data: Type.Optional(Type.String({ description: "Data to write for write" })),
|
||||||
|
keys: Type.Optional(
|
||||||
|
Type.Array(Type.String(), { description: "Key tokens to send for send-keys" }),
|
||||||
|
),
|
||||||
|
hex: Type.Optional(
|
||||||
|
Type.Array(Type.String(), { description: "Hex bytes to send for send-keys" }),
|
||||||
|
),
|
||||||
|
literal: Type.Optional(Type.String({ description: "Literal string for send-keys" })),
|
||||||
|
text: Type.Optional(Type.String({ description: "Text to paste for paste" })),
|
||||||
|
bracketed: Type.Optional(Type.Boolean({ description: "Wrap paste in bracketed mode" })),
|
||||||
eof: Type.Optional(Type.Boolean({ description: "Close stdin after write" })),
|
eof: Type.Optional(Type.Boolean({ description: "Close stdin after write" })),
|
||||||
offset: Type.Optional(Type.Number({ description: "Log offset" })),
|
offset: Type.Optional(Type.Number({ description: "Log offset" })),
|
||||||
limit: Type.Optional(Type.Number({ description: "Log length" })),
|
limit: Type.Optional(Type.Number({ description: "Log length" })),
|
||||||
@@ -48,13 +58,18 @@ export function createProcessTool(
|
|||||||
return {
|
return {
|
||||||
name: "process",
|
name: "process",
|
||||||
label: "process",
|
label: "process",
|
||||||
description: "Manage running exec sessions: list, poll, log, write, kill.",
|
description: "Manage running exec sessions: list, poll, log, write, send-keys, paste, kill.",
|
||||||
parameters: processSchema,
|
parameters: processSchema,
|
||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args) => {
|
||||||
const params = args as {
|
const params = args as {
|
||||||
action: "list" | "poll" | "log" | "write" | "kill" | "clear" | "remove";
|
action: "list" | "poll" | "log" | "write" | "send-keys" | "paste" | "kill" | "clear" | "remove";
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
data?: string;
|
data?: string;
|
||||||
|
keys?: string[];
|
||||||
|
hex?: string[];
|
||||||
|
literal?: string;
|
||||||
|
text?: string;
|
||||||
|
bracketed?: boolean;
|
||||||
eof?: boolean;
|
eof?: boolean;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -340,6 +355,148 @@ export function createProcessTool(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "send-keys": {
|
||||||
|
if (!scopedSession) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `No active session found for ${params.sessionId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { status: "failed" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!scopedSession.backgrounded) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Session ${params.sessionId} is not backgrounded.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { status: "failed" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const stdin = scopedSession.stdin ?? scopedSession.child?.stdin;
|
||||||
|
if (!stdin || stdin.destroyed) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Session ${params.sessionId} stdin is not writable.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { status: "failed" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { data, warnings } = encodeKeySequence({
|
||||||
|
keys: params.keys,
|
||||||
|
hex: params.hex,
|
||||||
|
literal: params.literal,
|
||||||
|
});
|
||||||
|
if (!data) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "No key data provided.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { status: "failed" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
stdin.write(data, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
`Sent ${data.length} bytes to session ${params.sessionId}.` +
|
||||||
|
(warnings.length ? `\nWarnings:\n- ${warnings.join("\n- ")}` : ""),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
status: "running",
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
name: scopedSession ? deriveSessionName(scopedSession.command) : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "paste": {
|
||||||
|
if (!scopedSession) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `No active session found for ${params.sessionId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { status: "failed" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!scopedSession.backgrounded) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Session ${params.sessionId} is not backgrounded.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { status: "failed" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const stdin = scopedSession.stdin ?? scopedSession.child?.stdin;
|
||||||
|
if (!stdin || stdin.destroyed) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Session ${params.sessionId} stdin is not writable.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { status: "failed" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const payload = encodePaste(params.text ?? "", params.bracketed !== false);
|
||||||
|
if (!payload) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "No paste text provided.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { status: "failed" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
stdin.write(payload, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Pasted ${params.text?.length ?? 0} chars to session ${params.sessionId}.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
status: "running",
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
name: scopedSession ? deriveSessionName(scopedSession.command) : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case "kill": {
|
case "kill": {
|
||||||
if (!scopedSession) {
|
if (!scopedSession) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
33
src/agents/pty-keys.test.ts
Normal file
33
src/agents/pty-keys.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, encodeKeySequence, encodePaste } from "./pty-keys.js";
|
||||||
|
|
||||||
|
test("encodeKeySequence maps common keys and modifiers", () => {
|
||||||
|
const enter = encodeKeySequence({ keys: ["Enter"] });
|
||||||
|
expect(enter.data).toBe("\r");
|
||||||
|
|
||||||
|
const ctrlC = encodeKeySequence({ keys: ["C-c"] });
|
||||||
|
expect(ctrlC.data).toBe("\x03");
|
||||||
|
|
||||||
|
const altX = encodeKeySequence({ keys: ["M-x"] });
|
||||||
|
expect(altX.data).toBe("\x1bx");
|
||||||
|
|
||||||
|
const shiftTab = encodeKeySequence({ keys: ["S-Tab"] });
|
||||||
|
expect(shiftTab.data).toBe("\x1b[Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("encodeKeySequence supports hex + literal with warnings", () => {
|
||||||
|
const result = encodeKeySequence({
|
||||||
|
literal: "hi",
|
||||||
|
hex: ["0d", "0x0a", "zz"],
|
||||||
|
keys: ["Enter"],
|
||||||
|
});
|
||||||
|
expect(result.data).toBe("hi\r\n\r");
|
||||||
|
expect(result.warnings.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("encodePaste wraps bracketed sequences by default", () => {
|
||||||
|
const payload = encodePaste("line1\nline2\n");
|
||||||
|
expect(payload.startsWith(BRACKETED_PASTE_START)).toBe(true);
|
||||||
|
expect(payload.endsWith(BRACKETED_PASTE_END)).toBe(true);
|
||||||
|
});
|
||||||
245
src/agents/pty-keys.ts
Normal file
245
src/agents/pty-keys.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
const ESC = "\x1b";
|
||||||
|
const CR = "\r";
|
||||||
|
const TAB = "\t";
|
||||||
|
const BACKSPACE = "\x7f";
|
||||||
|
|
||||||
|
export const BRACKETED_PASTE_START = `${ESC}[200~`;
|
||||||
|
export const BRACKETED_PASTE_END = `${ESC}[201~`;
|
||||||
|
|
||||||
|
type Modifiers = {
|
||||||
|
ctrl: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const namedKeyMap = new Map<string, string>([
|
||||||
|
["enter", CR],
|
||||||
|
["return", CR],
|
||||||
|
["tab", TAB],
|
||||||
|
["escape", ESC],
|
||||||
|
["esc", ESC],
|
||||||
|
["space", " "],
|
||||||
|
["bspace", BACKSPACE],
|
||||||
|
["backspace", BACKSPACE],
|
||||||
|
["up", `${ESC}[A`],
|
||||||
|
["down", `${ESC}[B`],
|
||||||
|
["right", `${ESC}[C`],
|
||||||
|
["left", `${ESC}[D`],
|
||||||
|
["home", `${ESC}[1~`],
|
||||||
|
["end", `${ESC}[4~`],
|
||||||
|
["pageup", `${ESC}[5~`],
|
||||||
|
["pgup", `${ESC}[5~`],
|
||||||
|
["ppage", `${ESC}[5~`],
|
||||||
|
["pagedown", `${ESC}[6~`],
|
||||||
|
["pgdn", `${ESC}[6~`],
|
||||||
|
["npage", `${ESC}[6~`],
|
||||||
|
["insert", `${ESC}[2~`],
|
||||||
|
["ic", `${ESC}[2~`],
|
||||||
|
["delete", `${ESC}[3~`],
|
||||||
|
["del", `${ESC}[3~`],
|
||||||
|
["dc", `${ESC}[3~`],
|
||||||
|
["btab", `${ESC}[Z`],
|
||||||
|
["f1", `${ESC}OP`],
|
||||||
|
["f2", `${ESC}OQ`],
|
||||||
|
["f3", `${ESC}OR`],
|
||||||
|
["f4", `${ESC}OS`],
|
||||||
|
["f5", `${ESC}[15~`],
|
||||||
|
["f6", `${ESC}[17~`],
|
||||||
|
["f7", `${ESC}[18~`],
|
||||||
|
["f8", `${ESC}[19~`],
|
||||||
|
["f9", `${ESC}[20~`],
|
||||||
|
["f10", `${ESC}[21~`],
|
||||||
|
["f11", `${ESC}[23~`],
|
||||||
|
["f12", `${ESC}[24~`],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const modifiableNamedKeys = new Set([
|
||||||
|
"up",
|
||||||
|
"down",
|
||||||
|
"left",
|
||||||
|
"right",
|
||||||
|
"home",
|
||||||
|
"end",
|
||||||
|
"pageup",
|
||||||
|
"pgup",
|
||||||
|
"ppage",
|
||||||
|
"pagedown",
|
||||||
|
"pgdn",
|
||||||
|
"npage",
|
||||||
|
"insert",
|
||||||
|
"ic",
|
||||||
|
"delete",
|
||||||
|
"del",
|
||||||
|
"dc",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type KeyEncodingRequest = {
|
||||||
|
keys?: string[];
|
||||||
|
hex?: string[];
|
||||||
|
literal?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KeyEncodingResult = {
|
||||||
|
data: string;
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function encodeKeySequence(request: KeyEncodingRequest): KeyEncodingResult {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
let data = "";
|
||||||
|
|
||||||
|
if (request.literal) {
|
||||||
|
data += request.literal;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.hex?.length) {
|
||||||
|
for (const raw of request.hex) {
|
||||||
|
const byte = parseHexByte(raw);
|
||||||
|
if (byte === null) {
|
||||||
|
warnings.push(`Invalid hex byte: ${raw}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
data += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.keys?.length) {
|
||||||
|
for (const token of request.keys) {
|
||||||
|
data += encodeKeyToken(token, warnings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodePaste(text: string, bracketed = true): string {
|
||||||
|
if (!bracketed) return text;
|
||||||
|
return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeKeyToken(raw: string, warnings: string[]): string {
|
||||||
|
const token = raw.trim();
|
||||||
|
if (!token) return "";
|
||||||
|
|
||||||
|
if (token.length === 2 && token.startsWith("^")) {
|
||||||
|
const ctrl = toCtrlChar(token[1]);
|
||||||
|
if (ctrl) return ctrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseModifiers(token);
|
||||||
|
const base = parsed.base;
|
||||||
|
const baseLower = base.toLowerCase();
|
||||||
|
|
||||||
|
if (baseLower === "tab" && parsed.mods.shift) {
|
||||||
|
return `${ESC}[Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseSeq = namedKeyMap.get(baseLower);
|
||||||
|
if (baseSeq) {
|
||||||
|
let seq = baseSeq;
|
||||||
|
if (modifiableNamedKeys.has(baseLower) && hasAnyModifier(parsed.mods)) {
|
||||||
|
const mod = xtermModifier(parsed.mods);
|
||||||
|
if (mod > 1) {
|
||||||
|
const modified = applyXtermModifier(seq, mod);
|
||||||
|
if (modified) {
|
||||||
|
seq = modified;
|
||||||
|
return seq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed.mods.alt) {
|
||||||
|
return `${ESC}${seq}`;
|
||||||
|
}
|
||||||
|
return seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (base.length === 1) {
|
||||||
|
return applyCharModifiers(base, parsed.mods);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.hasModifiers) {
|
||||||
|
warnings.push(`Unknown key "${base}" for modifiers; sending literal.`);
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseModifiers(token: string) {
|
||||||
|
const mods: Modifiers = { ctrl: false, alt: false, shift: false };
|
||||||
|
let rest = token;
|
||||||
|
let sawModifiers = false;
|
||||||
|
|
||||||
|
while (rest.length > 2 && rest[1] === "-") {
|
||||||
|
const mod = rest[0].toLowerCase();
|
||||||
|
if (mod === "c") mods.ctrl = true;
|
||||||
|
else if (mod === "m") mods.alt = true;
|
||||||
|
else if (mod === "s") mods.shift = true;
|
||||||
|
else break;
|
||||||
|
sawModifiers = true;
|
||||||
|
rest = rest.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mods, base: rest, hasModifiers: sawModifiers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCharModifiers(char: string, mods: Modifiers): string {
|
||||||
|
let value = char;
|
||||||
|
if (mods.shift && value.length === 1 && /[a-z]/.test(value)) {
|
||||||
|
value = value.toUpperCase();
|
||||||
|
}
|
||||||
|
if (mods.ctrl) {
|
||||||
|
const ctrl = toCtrlChar(value);
|
||||||
|
if (ctrl) value = ctrl;
|
||||||
|
}
|
||||||
|
if (mods.alt) {
|
||||||
|
value = `${ESC}${value}`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCtrlChar(char: string): string | null {
|
||||||
|
if (char.length !== 1) return null;
|
||||||
|
if (char === "?") return "\x7f";
|
||||||
|
const code = char.toUpperCase().charCodeAt(0);
|
||||||
|
if (code >= 64 && code <= 95) {
|
||||||
|
return String.fromCharCode(code & 0x1f);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function xtermModifier(mods: Modifiers): number {
|
||||||
|
let mod = 1;
|
||||||
|
if (mods.shift) mod += 1;
|
||||||
|
if (mods.alt) mod += 2;
|
||||||
|
if (mods.ctrl) mod += 4;
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyXtermModifier(sequence: string, modifier: number): string | null {
|
||||||
|
const csiNumber = /^\x1b\[(\d+)([~A-Z])$/;
|
||||||
|
const csiArrow = /^\x1b\[(A|B|C|D|H|F)$/;
|
||||||
|
|
||||||
|
const numberMatch = sequence.match(csiNumber);
|
||||||
|
if (numberMatch) {
|
||||||
|
return `${ESC}[${numberMatch[1]};${modifier}${numberMatch[2]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrowMatch = sequence.match(csiArrow);
|
||||||
|
if (arrowMatch) {
|
||||||
|
return `${ESC}[1;${modifier}${arrowMatch[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnyModifier(mods: Modifiers): boolean {
|
||||||
|
return mods.ctrl || mods.alt || mods.shift;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHexByte(raw: string): number | null {
|
||||||
|
const trimmed = raw.trim().toLowerCase();
|
||||||
|
const normalized = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed;
|
||||||
|
if (!/^[0-9a-f]{1,2}$/.test(normalized)) return null;
|
||||||
|
const value = Number.parseInt(normalized, 16);
|
||||||
|
if (Number.isNaN(value) || value < 0 || value > 0xff) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user