jules-orchestrator-kit 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -89
- package/bin/agentctl.mjs +205 -6
- package/index.mjs +21 -1
- package/package.json +1 -1
- package/src/config.mjs +18 -2
- package/src/dag-engine.mjs +76 -0
- package/src/dashboard.mjs +2 -2
- package/src/engine.mjs +296 -37
- package/src/envelope.mjs +24 -2
- package/src/mcp.mjs +35 -1
- package/src/ops/checkpoint.mjs +167 -0
- package/src/ops/command-registry.mjs +274 -0
- package/src/ops/doctor-planner.mjs +214 -0
- package/src/ops/doctor-registry.mjs +442 -0
- package/src/ops/ide-scaffold.mjs +139 -0
- package/src/ops/receipts.mjs +171 -0
- package/src/ops/swarm-actions.mjs +156 -0
- package/src/ops/task-actions.mjs +214 -0
- package/src/ops/tdd-generator.mjs +115 -0
- package/src/ops/transaction.mjs +278 -0
- package/src/provider.mjs +164 -8
- package/src/security.mjs +15 -7
- package/src/stack-detector.mjs +15 -5
- package/src/state.mjs +44 -7
- package/src/task-optimizer.mjs +306 -0
- package/src/telemetry.mjs +35 -2
- package/src/tui.mjs +4 -1
- package/src/ux/capabilities.mjs +180 -0
- package/src/ux/diff-viewer.mjs +289 -0
- package/src/ux/key-decoder.mjs +281 -0
- package/src/ux/layout.mjs +195 -0
- package/src/ux/log-viewer.mjs +110 -0
- package/src/ux/palette.mjs +179 -0
- package/src/ux/queue-model.mjs +206 -0
- package/src/ux/renderer.mjs +188 -0
- package/src/ux/swarm-model.mjs +146 -0
- package/src/ux/terminal-session.mjs +215 -0
- package/src/ux/widgets.mjs +323 -0
- package/src/webhook.mjs +120 -0
- package/src/wizard-init.mjs +41 -11
- package/src/wizard-task.mjs +51 -15
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { sanitizeControlChars } from "./renderer.mjs";
|
|
2
|
+
import { clipText } from "./layout.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {Object} DiffLine
|
|
6
|
+
* @property {"context" | "add" | "delete" | "header" | "no-newline"} kind
|
|
7
|
+
* @property {number} [oldLine]
|
|
8
|
+
* @property {number} [newLine]
|
|
9
|
+
* @property {string} text
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} DiffHunk
|
|
14
|
+
* @property {string} header
|
|
15
|
+
* @property {number} oldStart
|
|
16
|
+
* @property {number} oldCount
|
|
17
|
+
* @property {number} newStart
|
|
18
|
+
* @property {number} newCount
|
|
19
|
+
* @property {DiffLine[]} lines
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} DiffFile
|
|
24
|
+
* @property {string} oldPath
|
|
25
|
+
* @property {string} newPath
|
|
26
|
+
* @property {"added" | "modified" | "deleted" | "renamed" | "binary"} status
|
|
27
|
+
* @property {DiffHunk[]} hunks
|
|
28
|
+
* @property {number} additions
|
|
29
|
+
* @property {number} deletions
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} DiffDocument
|
|
34
|
+
* @property {DiffFile[]} files
|
|
35
|
+
* @property {boolean} truncated
|
|
36
|
+
* @property {number} totalBytes
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse unified diff text into structured DiffDocument.
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @param {Object} [options]
|
|
43
|
+
* @param {number} [options.maxBytes=1000000]
|
|
44
|
+
* @param {number} [options.maxFiles=100]
|
|
45
|
+
* @param {number} [options.maxLines=5000]
|
|
46
|
+
* @returns {DiffDocument}
|
|
47
|
+
*/
|
|
48
|
+
export function parseUnifiedDiff(text, options = {}) {
|
|
49
|
+
const maxBytes = options.maxBytes ?? 1_000_000;
|
|
50
|
+
const maxFiles = options.maxFiles ?? 100;
|
|
51
|
+
const maxLines = options.maxLines ?? 5000;
|
|
52
|
+
|
|
53
|
+
let truncated = false;
|
|
54
|
+
text = typeof text === "string" ? text : String(text || "");
|
|
55
|
+
const totalBytes = Buffer.byteLength(text, "utf-8");
|
|
56
|
+
const byteLimit = Number.isFinite(maxBytes) ? Math.max(0, Math.floor(maxBytes)) : 1_000_000;
|
|
57
|
+
|
|
58
|
+
if (totalBytes > byteLimit) {
|
|
59
|
+
text = Buffer.from(text, "utf-8").subarray(0, byteLimit).toString("utf-8");
|
|
60
|
+
truncated = true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const rawLines = text.split(/\r?\n/);
|
|
64
|
+
const files = [];
|
|
65
|
+
|
|
66
|
+
let currentFile = null;
|
|
67
|
+
let currentHunk = null;
|
|
68
|
+
let oldLineNum = 0;
|
|
69
|
+
let newLineNum = 0;
|
|
70
|
+
let lineCount = 0;
|
|
71
|
+
|
|
72
|
+
for (let rawIndex = 0; rawIndex < rawLines.length; rawIndex++) {
|
|
73
|
+
const rawLine = rawLines[rawIndex];
|
|
74
|
+
if (lineCount >= maxLines) {
|
|
75
|
+
truncated = true;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
lineCount++;
|
|
79
|
+
|
|
80
|
+
const line = sanitizeControlChars(rawLine);
|
|
81
|
+
if (line === "" && rawLine === "" && rawIndex === rawLines.length - 1) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (line.startsWith("diff --git ")) {
|
|
86
|
+
if (files.length >= maxFiles) {
|
|
87
|
+
truncated = true;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
const parts = line.split(" ");
|
|
91
|
+
const oldPath = parts[2] ? parts[2].replace(/^a\//, "") : "";
|
|
92
|
+
const newPath = parts[3] ? parts[3].replace(/^b\//, "") : "";
|
|
93
|
+
|
|
94
|
+
currentFile = {
|
|
95
|
+
oldPath,
|
|
96
|
+
newPath,
|
|
97
|
+
status: "modified",
|
|
98
|
+
hunks: [],
|
|
99
|
+
additions: 0,
|
|
100
|
+
deletions: 0,
|
|
101
|
+
};
|
|
102
|
+
files.push(currentFile);
|
|
103
|
+
currentHunk = null;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!currentFile && (line.startsWith("--- ") || line.startsWith("+++ "))) {
|
|
108
|
+
currentFile = {
|
|
109
|
+
oldPath: "",
|
|
110
|
+
newPath: "",
|
|
111
|
+
status: "modified",
|
|
112
|
+
hunks: [],
|
|
113
|
+
additions: 0,
|
|
114
|
+
deletions: 0,
|
|
115
|
+
};
|
|
116
|
+
files.push(currentFile);
|
|
117
|
+
}
|
|
118
|
+
if (!currentFile) continue;
|
|
119
|
+
|
|
120
|
+
if (!currentHunk && line.startsWith("--- ")) {
|
|
121
|
+
currentFile.oldPath = line.slice(4).replace(/^a\//, "");
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!currentHunk && line.startsWith("+++ ")) {
|
|
125
|
+
currentFile.newPath = line.slice(4).replace(/^b\//, "");
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (line.startsWith("new file mode ")) {
|
|
130
|
+
currentFile.status = "added";
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (line.startsWith("deleted file mode ")) {
|
|
134
|
+
currentFile.status = "deleted";
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (line.startsWith("similarity index ") || line.startsWith("rename from ") || line.startsWith("rename to ")) {
|
|
138
|
+
currentFile.status = "renamed";
|
|
139
|
+
if (line.startsWith("rename from ")) currentFile.oldPath = line.slice("rename from ".length);
|
|
140
|
+
if (line.startsWith("rename to ")) currentFile.newPath = line.slice("rename to ".length);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (line.startsWith("Binary files ")) {
|
|
144
|
+
currentFile.status = "binary";
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Hunk header: @@ -oldStart,oldCount +newStart,newCount @@
|
|
149
|
+
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
150
|
+
if (hunkMatch) {
|
|
151
|
+
oldLineNum = parseInt(hunkMatch[1], 10);
|
|
152
|
+
const oldCount = hunkMatch[2] !== undefined ? parseInt(hunkMatch[2], 10) : 1;
|
|
153
|
+
newLineNum = parseInt(hunkMatch[3], 10);
|
|
154
|
+
const newCount = hunkMatch[4] !== undefined ? parseInt(hunkMatch[4], 10) : 1;
|
|
155
|
+
|
|
156
|
+
currentHunk = {
|
|
157
|
+
header: line,
|
|
158
|
+
oldStart: oldLineNum,
|
|
159
|
+
oldCount,
|
|
160
|
+
newStart: newLineNum,
|
|
161
|
+
newCount,
|
|
162
|
+
lines: [],
|
|
163
|
+
};
|
|
164
|
+
currentFile.hunks.push(currentHunk);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!currentHunk) continue;
|
|
169
|
+
|
|
170
|
+
if (line.startsWith("+")) {
|
|
171
|
+
currentFile.additions++;
|
|
172
|
+
currentHunk.lines.push({
|
|
173
|
+
kind: "add",
|
|
174
|
+
newLine: newLineNum++,
|
|
175
|
+
text: line.slice(1),
|
|
176
|
+
});
|
|
177
|
+
} else if (line.startsWith("-")) {
|
|
178
|
+
currentFile.deletions++;
|
|
179
|
+
currentHunk.lines.push({
|
|
180
|
+
kind: "delete",
|
|
181
|
+
oldLine: oldLineNum++,
|
|
182
|
+
text: line.slice(1),
|
|
183
|
+
});
|
|
184
|
+
} else if (line.startsWith("\\ No newline")) {
|
|
185
|
+
currentHunk.lines.push({
|
|
186
|
+
kind: "no-newline",
|
|
187
|
+
text: line,
|
|
188
|
+
});
|
|
189
|
+
} else {
|
|
190
|
+
currentHunk.lines.push({
|
|
191
|
+
kind: "context",
|
|
192
|
+
oldLine: oldLineNum++,
|
|
193
|
+
newLine: newLineNum++,
|
|
194
|
+
text: line.startsWith(" ") ? line.slice(1) : line,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { files, truncated, totalBytes };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Render unified diff view frame.
|
|
204
|
+
* @param {DiffDocument} document
|
|
205
|
+
* @param {Record<string, any>} state
|
|
206
|
+
* @param {import("./capabilities.mjs").TerminalCapabilities} caps
|
|
207
|
+
* @returns {import("./renderer.mjs").RenderFrame}
|
|
208
|
+
*/
|
|
209
|
+
export function renderDiffView(document, state, caps) {
|
|
210
|
+
const width = caps.columns || 80;
|
|
211
|
+
const height = caps.rows || 24;
|
|
212
|
+
|
|
213
|
+
const fileIdx = Math.max(0, Math.min((document.files.length || 1) - 1, state.fileIdx || 0));
|
|
214
|
+
const currentFile = document.files[fileIdx];
|
|
215
|
+
|
|
216
|
+
const lines = [];
|
|
217
|
+
|
|
218
|
+
// Header line
|
|
219
|
+
const title = ` Diff · File [${fileIdx + 1}/${document.files.length || 0}] ${currentFile ? currentFile.newPath || currentFile.oldPath : ""} `;
|
|
220
|
+
lines.push({
|
|
221
|
+
runs: [
|
|
222
|
+
{ text: clipText(title, width), style: { fg: "cyan", bold: true, inverse: true } },
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
if (!currentFile) {
|
|
227
|
+
lines.push({ runs: [{ text: "No files changed.", style: { dim: true } }] });
|
|
228
|
+
return { width, height, lines };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// File summary line
|
|
232
|
+
const metaStr = ` Status: ${currentFile.status.toUpperCase()} +${currentFile.additions} -${currentFile.deletions} `;
|
|
233
|
+
lines.push({ runs: [{ text: metaStr, style: { fg: "yellow" } }] });
|
|
234
|
+
|
|
235
|
+
let lineOffset = state.lineOffset || 0;
|
|
236
|
+
let renderCount = 0;
|
|
237
|
+
const maxBodyLines = height - 3;
|
|
238
|
+
|
|
239
|
+
for (const hunk of currentFile.hunks) {
|
|
240
|
+
if (renderCount >= maxBodyLines) break;
|
|
241
|
+
|
|
242
|
+
// Hunk header line
|
|
243
|
+
lines.push({
|
|
244
|
+
runs: [{ text: clipText(hunk.header, width), style: { fg: "cyan", dim: true } }],
|
|
245
|
+
});
|
|
246
|
+
renderCount++;
|
|
247
|
+
|
|
248
|
+
for (const line of hunk.lines) {
|
|
249
|
+
if (renderCount >= maxBodyLines) break;
|
|
250
|
+
|
|
251
|
+
if (lineOffset > 0) {
|
|
252
|
+
lineOffset--;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const oldNumStr = line.oldLine !== undefined ? String(line.oldLine).padStart(5) : " ";
|
|
257
|
+
const newNumStr = line.newLine !== undefined ? String(line.newLine).padStart(5) : " ";
|
|
258
|
+
const numCol = `${oldNumStr} | ${newNumStr} | `;
|
|
259
|
+
|
|
260
|
+
/** @type {import("./renderer.mjs").TextStyle} */
|
|
261
|
+
let style = { fg: "default" };
|
|
262
|
+
let prefix = " ";
|
|
263
|
+
|
|
264
|
+
if (line.kind === "add") {
|
|
265
|
+
style = { fg: "green" };
|
|
266
|
+
prefix = "+";
|
|
267
|
+
} else if (line.kind === "delete") {
|
|
268
|
+
style = { fg: "red" };
|
|
269
|
+
prefix = "-";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const lineText = clipText(`${numCol}${prefix}${line.text}`, width);
|
|
273
|
+
lines.push({ runs: [{ text: lineText, style }] });
|
|
274
|
+
renderCount++;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Footer keybindings line
|
|
279
|
+
lines.push({
|
|
280
|
+
runs: [
|
|
281
|
+
{
|
|
282
|
+
text: clipText("[/]: switch file n/N: hunk Up/Down: scroll q: exit", width),
|
|
283
|
+
style: { fg: "cyan", dim: true },
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
return { width, height, lines };
|
|
289
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { StringDecoder } from "node:string_decoder";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {"up" | "down" | "left" | "right" | "page-up" | "page-down" | "home" | "end" | "space" | "enter" | "tab" | "shift-tab" | "escape" | "ctrl-c" | "backspace" | "delete" | "character" | "unknown"} KeyName
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} KeyEvent
|
|
9
|
+
* @property {KeyName} name
|
|
10
|
+
* @property {string} sequence
|
|
11
|
+
* @property {string} [text]
|
|
12
|
+
* @property {boolean} ctrl
|
|
13
|
+
* @property {boolean} alt
|
|
14
|
+
* @property {boolean} shift
|
|
15
|
+
* @property {number} timestampMs
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const SEQUENCE_MAP = new Map([
|
|
19
|
+
// Arrow keys
|
|
20
|
+
["\x1b[A", { name: "up", ctrl: false, alt: false, shift: false }],
|
|
21
|
+
["\x1bOA", { name: "up", ctrl: false, alt: false, shift: false }],
|
|
22
|
+
["\x1b[B", { name: "down", ctrl: false, alt: false, shift: false }],
|
|
23
|
+
["\x1bOB", { name: "down", ctrl: false, alt: false, shift: false }],
|
|
24
|
+
["\x1b[C", { name: "right", ctrl: false, alt: false, shift: false }],
|
|
25
|
+
["\x1bOC", { name: "right", ctrl: false, alt: false, shift: false }],
|
|
26
|
+
["\x1b[D", { name: "left", ctrl: false, alt: false, shift: false }],
|
|
27
|
+
["\x1bOD", { name: "left", ctrl: false, alt: false, shift: false }],
|
|
28
|
+
|
|
29
|
+
// Home / End
|
|
30
|
+
["\x1b[H", { name: "home", ctrl: false, alt: false, shift: false }],
|
|
31
|
+
["\x1bOH", { name: "home", ctrl: false, alt: false, shift: false }],
|
|
32
|
+
["\x1b[1~", { name: "home", ctrl: false, alt: false, shift: false }],
|
|
33
|
+
["\x1b[7~", { name: "home", ctrl: false, alt: false, shift: false }],
|
|
34
|
+
["\x1b[F", { name: "end", ctrl: false, alt: false, shift: false }],
|
|
35
|
+
["\x1bOF", { name: "end", ctrl: false, alt: false, shift: false }],
|
|
36
|
+
["\x1b[4~", { name: "end", ctrl: false, alt: false, shift: false }],
|
|
37
|
+
["\x1b[8~", { name: "end", ctrl: false, alt: false, shift: false }],
|
|
38
|
+
|
|
39
|
+
// Navigation
|
|
40
|
+
["\x1b[5~", { name: "page-up", ctrl: false, alt: false, shift: false }],
|
|
41
|
+
["\x1b[6~", { name: "page-down", ctrl: false, alt: false, shift: false }],
|
|
42
|
+
["\x1b[Z", { name: "shift-tab", ctrl: false, alt: false, shift: true }],
|
|
43
|
+
["\x1b[3~", { name: "delete", ctrl: false, alt: false, shift: false }],
|
|
44
|
+
|
|
45
|
+
// Single-byte controls
|
|
46
|
+
["\r", { name: "enter", ctrl: false, alt: false, shift: false }],
|
|
47
|
+
["\n", { name: "enter", ctrl: false, alt: false, shift: false }],
|
|
48
|
+
["\t", { name: "tab", ctrl: false, alt: false, shift: false }],
|
|
49
|
+
["\x03", { name: "ctrl-c", ctrl: true, alt: false, shift: false }],
|
|
50
|
+
["\x7f", { name: "backspace", ctrl: false, alt: false, shift: false }],
|
|
51
|
+
["\x08", { name: "backspace", ctrl: false, alt: false, shift: false }],
|
|
52
|
+
[" ", { name: "space", text: " ", ctrl: false, alt: false, shift: false }],
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create an incremental sequence key decoder.
|
|
57
|
+
* @param {Object} [options]
|
|
58
|
+
* @param {number} [options.escapeTimeoutMs=30]
|
|
59
|
+
* @param {number} [options.maxBufferBytes=1024]
|
|
60
|
+
*/
|
|
61
|
+
export function createKeyDecoder(options = {}) {
|
|
62
|
+
const escapeTimeoutMs = options.escapeTimeoutMs ?? 30;
|
|
63
|
+
const maxBufferBytes = options.maxBufferBytes ?? 1024;
|
|
64
|
+
|
|
65
|
+
const utf8Decoder = new StringDecoder("utf8");
|
|
66
|
+
let buffer = "";
|
|
67
|
+
let lastTimeMs = 0;
|
|
68
|
+
|
|
69
|
+
function appendChunk(chunk) {
|
|
70
|
+
if (Buffer.isBuffer(chunk) || chunk instanceof Uint8Array) {
|
|
71
|
+
buffer += utf8Decoder.write(chunk);
|
|
72
|
+
} else if (chunk !== null && chunk !== undefined) {
|
|
73
|
+
buffer += String(chunk);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
while (Buffer.byteLength(buffer, "utf8") > maxBufferBytes) {
|
|
77
|
+
const firstCodePoint = buffer.codePointAt(0);
|
|
78
|
+
buffer = buffer.slice(firstCodePoint > 0xffff ? 2 : 1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Process a single token from buffer.
|
|
84
|
+
* @param {number} nowMs
|
|
85
|
+
* @returns {{ event: KeyEvent, consumed: number } | null}
|
|
86
|
+
*/
|
|
87
|
+
function tryDecodeToken(nowMs) {
|
|
88
|
+
if (!buffer) return null;
|
|
89
|
+
|
|
90
|
+
// Check exact sequence mappings
|
|
91
|
+
for (const [seq, meta] of SEQUENCE_MAP.entries()) {
|
|
92
|
+
if (buffer.startsWith(seq)) {
|
|
93
|
+
return {
|
|
94
|
+
event: {
|
|
95
|
+
name: meta.name,
|
|
96
|
+
sequence: seq,
|
|
97
|
+
text: meta.text,
|
|
98
|
+
ctrl: meta.ctrl,
|
|
99
|
+
alt: meta.alt,
|
|
100
|
+
shift: meta.shift,
|
|
101
|
+
timestampMs: nowMs,
|
|
102
|
+
},
|
|
103
|
+
consumed: seq.length,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Handle ESC prefix sequences
|
|
109
|
+
if (buffer[0] === "\x1b") {
|
|
110
|
+
if (buffer === "\x1b") {
|
|
111
|
+
// Wait for timeout to distinguish standalone ESC
|
|
112
|
+
if (nowMs - lastTimeMs >= escapeTimeoutMs) {
|
|
113
|
+
return {
|
|
114
|
+
event: {
|
|
115
|
+
name: "escape",
|
|
116
|
+
sequence: "\x1b",
|
|
117
|
+
ctrl: false,
|
|
118
|
+
alt: false,
|
|
119
|
+
shift: false,
|
|
120
|
+
timestampMs: nowMs,
|
|
121
|
+
},
|
|
122
|
+
consumed: 1,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// Needs more input
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// CSI or SS3 sequence parsing: ESC [ ... or ESC O ...
|
|
130
|
+
if (buffer[1] === "[" || buffer[1] === "O") {
|
|
131
|
+
let idx = 2;
|
|
132
|
+
while (idx < buffer.length) {
|
|
133
|
+
const charCode = buffer.charCodeAt(idx);
|
|
134
|
+
// Sequence terminator char is usually between 0x40 ('@') and 0x7E ('~')
|
|
135
|
+
if (charCode >= 0x40 && charCode <= 0x7e) {
|
|
136
|
+
const seq = buffer.slice(0, idx + 1);
|
|
137
|
+
const matched = SEQUENCE_MAP.get(seq);
|
|
138
|
+
if (matched) {
|
|
139
|
+
return {
|
|
140
|
+
event: {
|
|
141
|
+
name: matched.name,
|
|
142
|
+
sequence: seq,
|
|
143
|
+
text: matched.text,
|
|
144
|
+
ctrl: matched.ctrl,
|
|
145
|
+
alt: matched.alt,
|
|
146
|
+
shift: matched.shift,
|
|
147
|
+
timestampMs: nowMs,
|
|
148
|
+
},
|
|
149
|
+
consumed: seq.length,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// Unknown CSI/SS3 sequence - sanitize and emit as unknown
|
|
153
|
+
return {
|
|
154
|
+
event: {
|
|
155
|
+
name: "unknown",
|
|
156
|
+
sequence: seq,
|
|
157
|
+
text: seq.replace(/\x1b/g, "^["),
|
|
158
|
+
ctrl: false,
|
|
159
|
+
alt: false,
|
|
160
|
+
shift: false,
|
|
161
|
+
timestampMs: nowMs,
|
|
162
|
+
},
|
|
163
|
+
consumed: seq.length,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
idx++;
|
|
167
|
+
}
|
|
168
|
+
// Incomplete CSI/SS3 sequence in buffer, wait unless buffer exceeded cap
|
|
169
|
+
if (Buffer.byteLength(buffer, "utf8") < maxBufferBytes) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Alt+Character (ESC followed by a single printable character)
|
|
175
|
+
const altChar = Array.from(buffer.slice(1))[0];
|
|
176
|
+
if (altChar && altChar.codePointAt(0) >= 0x20 && altChar.codePointAt(0) <= 0x7e) {
|
|
177
|
+
const seq = `\x1b${altChar}`;
|
|
178
|
+
return {
|
|
179
|
+
event: {
|
|
180
|
+
name: altChar === " " ? "space" : "character",
|
|
181
|
+
sequence: seq,
|
|
182
|
+
text: altChar,
|
|
183
|
+
ctrl: false,
|
|
184
|
+
alt: true,
|
|
185
|
+
shift: false,
|
|
186
|
+
timestampMs: nowMs,
|
|
187
|
+
},
|
|
188
|
+
consumed: seq.length,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Ctrl+Key (ASCII 1..26 except TAB \t, CR \r, LF \n, ETX \x03)
|
|
194
|
+
const code = buffer.charCodeAt(0);
|
|
195
|
+
if (code >= 1 && code <= 26 && code !== 9 && code !== 10 && code !== 13) {
|
|
196
|
+
const char = String.fromCharCode(code + 96);
|
|
197
|
+
return {
|
|
198
|
+
event: {
|
|
199
|
+
name: "character",
|
|
200
|
+
sequence: buffer[0],
|
|
201
|
+
text: char,
|
|
202
|
+
ctrl: true,
|
|
203
|
+
alt: false,
|
|
204
|
+
shift: false,
|
|
205
|
+
timestampMs: nowMs,
|
|
206
|
+
},
|
|
207
|
+
consumed: 1,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Printable single character or Unicode scalar
|
|
212
|
+
const firstCodePoint = buffer.codePointAt(0) || 0;
|
|
213
|
+
const firstChar = String.fromCodePoint(firstCodePoint);
|
|
214
|
+
if (firstCodePoint >= 0x20) {
|
|
215
|
+
return {
|
|
216
|
+
event: {
|
|
217
|
+
name: firstChar === " " ? "space" : "character",
|
|
218
|
+
sequence: firstChar,
|
|
219
|
+
text: firstChar,
|
|
220
|
+
ctrl: false,
|
|
221
|
+
alt: false,
|
|
222
|
+
shift: false,
|
|
223
|
+
timestampMs: nowMs,
|
|
224
|
+
},
|
|
225
|
+
consumed: firstChar.length,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Fallback unknown control byte
|
|
230
|
+
return {
|
|
231
|
+
event: {
|
|
232
|
+
name: "unknown",
|
|
233
|
+
sequence: firstChar,
|
|
234
|
+
ctrl: false,
|
|
235
|
+
alt: false,
|
|
236
|
+
shift: false,
|
|
237
|
+
timestampMs: nowMs,
|
|
238
|
+
},
|
|
239
|
+
consumed: firstChar.length,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
/**
|
|
245
|
+
* Push incoming chunk bytes/string and return decoded KeyEvents.
|
|
246
|
+
* @param {Buffer | string} chunk
|
|
247
|
+
* @param {number} [nowMs]
|
|
248
|
+
* @returns {KeyEvent[]}
|
|
249
|
+
*/
|
|
250
|
+
push(chunk, nowMs = Date.now()) {
|
|
251
|
+
appendChunk(chunk);
|
|
252
|
+
lastTimeMs = nowMs;
|
|
253
|
+
|
|
254
|
+
const events = [];
|
|
255
|
+
let result = tryDecodeToken(nowMs);
|
|
256
|
+
while (result) {
|
|
257
|
+
events.push(result.event);
|
|
258
|
+
buffer = buffer.slice(result.consumed);
|
|
259
|
+
result = tryDecodeToken(nowMs);
|
|
260
|
+
}
|
|
261
|
+
return events;
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Flush remaining buffer (e.g. after timeout).
|
|
266
|
+
* @param {number} [nowMs]
|
|
267
|
+
* @returns {KeyEvent[]}
|
|
268
|
+
*/
|
|
269
|
+
flush(nowMs = Date.now()) {
|
|
270
|
+
const events = [];
|
|
271
|
+
let result = tryDecodeToken(nowMs + escapeTimeoutMs + 1);
|
|
272
|
+
while (result) {
|
|
273
|
+
events.push(result.event);
|
|
274
|
+
buffer = buffer.slice(result.consumed);
|
|
275
|
+
result = tryDecodeToken(nowMs + escapeTimeoutMs + 1);
|
|
276
|
+
}
|
|
277
|
+
buffer = "";
|
|
278
|
+
return events;
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|