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.
@@ -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
+ }