@triflare/fx 2.1.0-beta.3 → 2.1.0-beta.5

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.
@@ -1,15 +1,104 @@
1
- import { CATALOG, blockNames } from "../../catalog.js";
1
+ import { CATALOG } from "../../catalog.js";
2
2
  import { loadProjectState } from "../context.js";
3
+ import { plural, terminalColumns } from "../format.js";
4
+
5
+ // Language order for the built-in categories; extension groups follow
6
+ // alphabetically so their blocks stay together at the end of the listing.
7
+ const CATEGORY_ORDER = [
8
+ "motion",
9
+ "looks",
10
+ "sound",
11
+ "sensing",
12
+ "operators",
13
+ "control",
14
+ "events",
15
+ "data",
16
+ ];
17
+
18
+ // One-character kind marker, so a name alone still says how the block is used.
19
+ const KIND_MARKS = {
20
+ command: " ",
21
+ reporter: "→",
22
+ boolean: "?",
23
+ hat: "▸",
24
+ };
25
+
26
+ /** `SCREAMING_SNAKE_CASE` name for a catalog argument. */
27
+ function argumentName(name) {
28
+ return name
29
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
30
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
31
+ .toUpperCase();
32
+ }
33
+
34
+ /** Positional arguments a block takes, in the order the compiler fills them. */
35
+ function argumentNames(entry) {
36
+ // `pm.*` are argument reporters whose VALUE field is the reporter's own
37
+ // label ("is compiled?"), not a parameter the caller supplies.
38
+ if (entry.opcode?.startsWith("argument_reporter")) return [];
39
+ if (entry.kind === "hat")
40
+ return (entry.args || []).map((arg) => arg.field || arg.input);
41
+ return [
42
+ ...Object.keys(entry.fields || {}),
43
+ ...Object.entries(entry.inputs || {})
44
+ .filter(([, spec]) => spec.type !== "substack")
45
+ .map(([name]) => name),
46
+ ];
47
+ }
48
+
49
+ /** `category.block(ARG, ...)` call shape shown in the listing. */
50
+ function signature(name, entry) {
51
+ const args = argumentNames(entry).map(argumentName);
52
+ return `${name}(${args.join(", ")})`;
53
+ }
54
+
55
+ function mark(entry) {
56
+ return KIND_MARKS[entry.kind] ?? " ";
57
+ }
58
+
59
+ /** Group block names by category, in reading order. */
60
+ function groupNames(names) {
61
+ const groups = new Map();
62
+ for (const name of names) {
63
+ const group = name.split(".")[0];
64
+ if (!groups.has(group)) groups.set(group, []);
65
+ groups.get(group).push(name);
66
+ }
67
+ const rank = (group) => {
68
+ const index = CATEGORY_ORDER.indexOf(group);
69
+ return index < 0 ? CATEGORY_ORDER.length : index;
70
+ };
71
+ return new Map(
72
+ [...groups.entries()].sort(
73
+ ([left], [right]) =>
74
+ rank(left) - rank(right) || left.localeCompare(right),
75
+ ),
76
+ );
77
+ }
3
78
 
4
79
  export const catalogCommand = {
5
80
  name: "catalog",
6
81
  aliases: ["l", "blocks"],
7
82
  group: "Core Commands:",
8
83
  summary: "List available blocks",
9
- description: `List block names, including blocks from extensions configured in the
10
- project.`,
84
+ description: `List block names grouped by category, including blocks from extensions
85
+ configured in the project.`,
11
86
  arguments: ["[dir]"],
12
- examples: ["fx catalog", "fx catalog --json"],
87
+ options: [
88
+ {
89
+ flags: "--category <name>",
90
+ description: "Only list one category, such as motion or pen",
91
+ },
92
+ {
93
+ flags: "--search <text>",
94
+ description: "Only list blocks whose name contains the text",
95
+ },
96
+ ],
97
+ examples: [
98
+ "fx catalog",
99
+ "fx catalog --category motion",
100
+ "fx catalog --search glide",
101
+ ],
13
102
  async run({ args, cwd, options, reporter, product, prompts }) {
14
103
  const state = await loadProjectState({
15
104
  input: args[0],
@@ -19,24 +108,69 @@ project.`,
19
108
  prompts,
20
109
  });
21
110
  const merged = { ...CATALOG, ...state.catalog };
22
- const names = Object.keys(merged).sort();
23
- const builtIn = blockNames().length;
24
-
25
- reporter.heading(
26
- `${product.name} catalog (${names.length} blocks` +
27
- (names.length > builtIn
28
- ? `, ${names.length - builtIn} from extensions`
29
- : "") +
30
- "):",
111
+ const category =
112
+ typeof options.category === "string"
113
+ ? options.category.toLowerCase()
114
+ : null;
115
+ const search =
116
+ typeof options.search === "string" ? options.search.toLowerCase() : null;
117
+ const names = Object.keys(merged)
118
+ .filter(
119
+ (name) => !category || name.split(".")[0].toLowerCase() === category,
120
+ )
121
+ .filter((name) => !search || name.toLowerCase().includes(search))
122
+ .sort();
123
+ const selected = Object.fromEntries(
124
+ names.map((name) => [name, merged[name]]),
31
125
  );
32
- for (const name of names) {
33
- const entry = merged[name];
34
- reporter.item(name, `-> ${entry.opcode} [${entry.kind}]`, {
35
- width: 32,
36
- suffix: entry.extension ? `(ext ${entry.extension})` : "",
37
- });
126
+
127
+ if (names.length === 0) {
128
+ const filters = [
129
+ category && `--category "${options.category}"`,
130
+ search && `--search "${options.search}"`,
131
+ ]
132
+ .filter(Boolean)
133
+ .join(" and ");
134
+ reporter.info(`No blocks match ${filters}.`);
135
+ reporter.hint(`run "${product.command} catalog" to see every block`);
136
+ return selected;
137
+ }
138
+
139
+ const groups = groupNames(names);
140
+ let title = `${product.name} catalog — ${plural(names.length, "block")}`;
141
+ if (category) title += ` · ${category}`;
142
+ else if (search) title += ` matching "${options.search}"`;
143
+ else title += ` in ${plural(groups.size, "group")}`;
144
+ reporter.heading(title);
145
+ if (!category && !search)
146
+ reporter.hint("narrow with --category <name> or --search <text>");
147
+
148
+ const width = terminalColumns();
149
+ for (const [group, entries] of groups) {
150
+ reporter.text("");
151
+ reporter.item(group, "", { width: 0, suffix: `· ${entries.length}` });
152
+ if (reporter.verbose) {
153
+ for (const name of entries) {
154
+ const entry = merged[name];
155
+ reporter.item(
156
+ `${mark(entry)} ${signature(name, entry)}`,
157
+ `-> ${entry.opcode} [${entry.kind}]`,
158
+ {
159
+ width: 44,
160
+ suffix: entry.extension ? `(ext ${entry.extension})` : "",
161
+ },
162
+ );
163
+ }
164
+ } else {
165
+ reporter.columns(
166
+ entries.map(
167
+ (name) => `${mark(merged[name])} ${signature(name, merged[name])}`,
168
+ ),
169
+ { width },
170
+ );
171
+ }
38
172
  }
39
173
 
40
- return merged;
174
+ return selected;
41
175
  },
42
176
  };
@@ -34,7 +34,7 @@ export const vendorOutdatedCommand = {
34
34
  : result.status === "error"
35
35
  ? "danger"
36
36
  : "success";
37
- reporter.item(result.name, reporter.tone(tone, label), { width: 24 });
37
+ reporter.item(result.name, label, { width: 24, tone });
38
38
  if (result.error) reporter.warn(result.error);
39
39
  }
40
40
  if (outdated > 0)
package/src/cli/format.js CHANGED
@@ -29,3 +29,55 @@ export function truncate(text, max) {
29
29
  const value = String(text);
30
30
  return value.length > max ? `${value.slice(0, max)}…` : value;
31
31
  }
32
+
33
+ /** Usable terminal width, clamped so narrow and huge terminals stay readable. */
34
+ export function terminalColumns(
35
+ stream = process.stdout,
36
+ { fallback = 80, min = 40, max = 120 } = {},
37
+ ) {
38
+ const width = stream?.columns;
39
+ if (!Number.isFinite(width) || width <= 0) return fallback;
40
+ return Math.min(max, Math.max(min, width));
41
+ }
42
+
43
+ /**
44
+ * Lay items out in aligned columns that fit `width`. Entries wider than one
45
+ * cell get a line of their own, so a single long name does not collapse the
46
+ * whole listing into one column. Trailing whitespace is removed.
47
+ */
48
+ export function formatColumns(
49
+ items,
50
+ { width = 80, indent = 2, gap = 2, cellWidth = 38 } = {},
51
+ ) {
52
+ if (items.length === 0) return [];
53
+ const available = Math.max(width - indent, 1);
54
+ const columns = Math.max(
55
+ 1,
56
+ Math.min(4, Math.floor((available + gap) / (cellWidth + gap))),
57
+ );
58
+ const cell = Math.floor((available - gap * (columns - 1)) / columns);
59
+ const lines = [];
60
+ let row = [];
61
+
62
+ const flush = () => {
63
+ if (row.length === 0) return;
64
+ const cells = row.map((text, index) =>
65
+ index === row.length - 1 ? text : text.padEnd(cell),
66
+ );
67
+ lines.push(`${" ".repeat(indent)}${cells.join(" ".repeat(gap))}`.trimEnd());
68
+ row = [];
69
+ };
70
+
71
+ for (const item of items) {
72
+ const text = String(item);
73
+ if (text.length > cell) {
74
+ flush();
75
+ lines.push(`${" ".repeat(indent)}${text}`);
76
+ continue;
77
+ }
78
+ row.push(text);
79
+ if (row.length === columns) flush();
80
+ }
81
+ flush();
82
+ return lines;
83
+ }
@@ -8,6 +8,8 @@
8
8
 
9
9
  import { styleText } from "node:util";
10
10
  import { brandText } from "../brand.js";
11
+ import { sanitizeTerminalText } from "../terminal.js";
12
+ import { formatColumns } from "./format.js";
11
13
  import { createTheme } from "./theme.js";
12
14
 
13
15
  const STATUS = {
@@ -59,7 +61,7 @@ export function createReporter({
59
61
  const glyph = mark(symbol);
60
62
  const stream = type === "success" ? stdout : stderr;
61
63
  const theme = themeFor(stream);
62
- const text = String(message);
64
+ const text = sanitizeTerminalText(message);
63
65
  const output = text
64
66
  .split("\n")
65
67
  .map((line, index) => {
@@ -101,48 +103,61 @@ export function createReporter({
101
103
 
102
104
  info(message) {
103
105
  if (!human) return;
104
- stdout.write(`${message}\n`);
106
+ stdout.write(`${sanitizeTerminalText(message)}\n`);
105
107
  },
106
108
 
107
109
  bullet(message) {
108
110
  if (!human) return;
109
111
  const theme = themeFor(stdout);
110
112
  const glyph = mark("bullet");
111
- stdout.write(` ${glyph ? `${theme.accent(glyph)} ` : ""}${message}\n`);
113
+ const text = sanitizeTerminalText(message);
114
+ stdout.write(` ${glyph ? `${theme.accent(glyph)} ` : ""}${text}\n`);
112
115
  },
113
116
 
114
117
  step(message) {
115
118
  if (!human) return;
116
119
  const theme = themeFor(stdout);
117
120
  const glyph = mark("progress");
118
- stdout.write(`${glyph ? `${theme.accent(glyph)} ` : ""}${message}\n`);
121
+ const text = sanitizeTerminalText(message);
122
+ stdout.write(`${glyph ? `${theme.accent(glyph)} ` : ""}${text}\n`);
119
123
  },
120
124
 
121
125
  heading(message) {
122
126
  if (!human) return;
123
- stdout.write(`${themeFor(stdout).heading(String(message))}\n`);
127
+ stdout.write(
128
+ `${themeFor(stdout).heading(sanitizeTerminalText(message))}\n`,
129
+ );
124
130
  },
125
131
 
126
132
  /** Aligned `label value` row used by command summaries. */
127
133
  detail(label, value, { width = 13 } = {}) {
128
134
  if (!human) return;
129
135
  const theme = themeFor(stdout);
130
- const text = String(value);
136
+ const text = sanitizeTerminalText(value);
131
137
  const rendered = text === "(none)" ? theme.muted(text) : theme.bold(text);
132
- stdout.write(
133
- ` ${theme.muted(String(label).padEnd(width))} ${rendered}\n`,
134
- );
138
+ const name = sanitizeTerminalText(label);
139
+ stdout.write(` ${theme.muted(name.padEnd(width))} ${rendered}\n`);
135
140
  },
136
141
 
137
142
  /** Aligned name + description row used by listings. */
138
- item(label, value = "", { width = 24, suffix = "" } = {}) {
143
+ item(label, value = "", { width = 24, suffix = "", tone } = {}) {
139
144
  if (!human) return;
140
145
  const theme = themeFor(stdout);
141
- const text = String(label);
146
+ const text = sanitizeTerminalText(label);
142
147
  const padded = text.padEnd(Math.max(width, text.length));
143
- const detail = value ? ` ${value}` : "";
144
- const note = suffix ? ` ${theme.muted(suffix)}` : "";
145
- stdout.write(` ${theme.accent(padded)}${detail}${note}\n`);
148
+ const detail = value ? ` ${sanitizeTerminalText(value)}` : "";
149
+ const note = suffix
150
+ ? ` ${theme.muted(sanitizeTerminalText(suffix))}`
151
+ : "";
152
+ const rendered = tone ? theme.tone(tone, detail) : detail;
153
+ stdout.write(` ${theme.accent(padded)}${rendered}${note}\n`);
154
+ },
155
+
156
+ /** Compact multi-column rows for long block and file listings. */
157
+ columns(items, { width = 80, indent = 2, gap = 2 } = {}) {
158
+ if (!human) return;
159
+ for (const line of formatColumns(items, { width, indent, gap }))
160
+ stdout.write(`${line}\n`);
146
161
  },
147
162
 
148
163
  /** Plain block of text with no decoration. */
@@ -163,7 +178,9 @@ export function createReporter({
163
178
  /** Quiet, de-emphasized line for incidental notes. */
164
179
  note(message) {
165
180
  if (!human) return;
166
- stdout.write(`${themeFor(stdout).muted(message)}\n`);
181
+ stdout.write(
182
+ `${themeFor(stdout).muted(sanitizeTerminalText(message))}\n`,
183
+ );
167
184
  },
168
185
 
169
186
  warn(message) {
@@ -183,9 +200,8 @@ export function createReporter({
183
200
  hint(message) {
184
201
  if (!human) return;
185
202
  const theme = themeFor(stdout);
186
- stdout.write(
187
- ` ${theme.accent("↳")} ${theme.muted(`hint: ${message}`)}\n`,
188
- );
203
+ const text = sanitizeTerminalText(message);
204
+ stdout.write(` ${theme.accent("↳")} ${theme.muted(`hint: ${text}`)}\n`);
189
205
  },
190
206
 
191
207
  /** One JSON document, used for the command result envelope. */
package/src/compiler.js CHANGED
@@ -45,6 +45,8 @@ const NUMBER_TYPES = {
45
45
  const TERMINAL_OPCODES = new Set([
46
46
  "procedures_return",
47
47
  "control_delete_this_clone",
48
+ "control_exitLoop",
49
+ "control_continueLoop",
48
50
  ]);
49
51
 
50
52
  let seed = 0n;
@@ -778,6 +780,9 @@ export class Compiler {
778
780
  if (block.opcode === "procedures_return") return "`return`";
779
781
  if (block.opcode === "control_delete_this_clone")
780
782
  return "`control.deleteThisClone()`";
783
+ if (block.opcode === "control_exitLoop") return "`control.escapeLoop()`";
784
+ if (block.opcode === "control_continueLoop")
785
+ return "`control.continueLoop()`";
781
786
  if (block.opcode === "control_stop") {
782
787
  const option = block.fields.STOP_OPTION
783
788
  ? block.fields.STOP_OPTION[0]
@@ -1034,14 +1039,17 @@ export class Compiler {
1034
1039
  */
1035
1040
  emitCall(ctx, node, parentId) {
1036
1041
  const segments = node.segments;
1042
+ const entry = this.catalog[node.name];
1037
1043
 
1038
1044
  // List method: `items.push(x)`, `items.at(i)`, ...
1039
1045
  if (segments.length === 2) {
1040
1046
  const listInfo = ctx.resolveList(segments[0]);
1041
- if (listInfo) return this.emitListCall(ctx, node, listInfo, parentId);
1047
+ // Canonical list methods take precedence; aliases resolve in emitListCall
1048
+ // only when the name does not identify a catalog block.
1049
+ if (listInfo && (Object.hasOwn(LIST_OPS, segments[1]) || !entry))
1050
+ return this.emitListCall(ctx, node, listInfo, parentId);
1042
1051
  }
1043
1052
 
1044
- const entry = this.catalog[node.name];
1045
1053
  if (entry) return this.emitCatalogCall(ctx, node, entry, parentId);
1046
1054
 
1047
1055
  if (segments.length === 1 && ctx.funcs.has(node.name)) {
@@ -1098,6 +1106,33 @@ export class Compiler {
1098
1106
  for (const name of inputNames) {
1099
1107
  const spec = entry.inputs[name];
1100
1108
  if (spec.type === "substack") continue;
1109
+ // Variable and list references are stored as fields in Scratch JSON
1110
+ // even though they read as positional arguments in Fx source.
1111
+ if (spec.type === "variable" || spec.type === "list") {
1112
+ if (node.args[argIndex] === undefined)
1113
+ this.fail(
1114
+ `${node.name} requires a ${spec.type} name for ${name}`,
1115
+ node.loc,
1116
+ );
1117
+ const refName = String(this.literalValue(node.args[argIndex]));
1118
+ const resolved =
1119
+ spec.type === "list"
1120
+ ? ctx.resolveList(refName)
1121
+ : ctx.resolveValue(refName);
1122
+ if (
1123
+ !resolved ||
1124
+ (spec.type === "list"
1125
+ ? resolved.kind !== "list"
1126
+ : resolved.kind !== "var")
1127
+ )
1128
+ this.fail(
1129
+ `Unknown ${spec.type} ${JSON.stringify(refName)} for ${node.name}`,
1130
+ node.loc,
1131
+ );
1132
+ fields[name] = [resolved.name, resolved.id];
1133
+ argIndex += 1;
1134
+ continue;
1135
+ }
1101
1136
  if (node.args[argIndex] !== undefined) {
1102
1137
  inputs[name] = this.buildInputForSpec(spec, node.args[argIndex], id);
1103
1138
  argIndex += 1;
package/src/decompiler.js CHANGED
@@ -18,7 +18,15 @@ const MENU_OPCODES = new Set([
18
18
  "looks_backdrops",
19
19
  "sound_sounds_menu",
20
20
  "control_create_clone_of_menu",
21
+ "control_stop_sprite_menu",
21
22
  "sensing_of_object_menu",
23
+ "sensing_fulltouchingobjectmenu",
24
+ "sensing_touchingobjectmenusprites",
25
+ "sensing_scrolldirections",
26
+ "sensing_fingeroptions",
27
+ "looks_changeVisibilityOfSprite_menu",
28
+ "looks_getOtherSpriteVisible_menu",
29
+ "looks_getinput_menu",
22
30
  "pen_menu_colorParam",
23
31
  ]);
24
32
 
@@ -50,6 +58,7 @@ const LIST_OPCODE_METHOD = {
50
58
  };
51
59
 
52
60
  const SAFE_TEMP_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
61
+ const SAFE_VAR_REF = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
53
62
 
54
63
  function escapeString(value) {
55
64
  return String(value)
@@ -236,6 +245,12 @@ class Decompiler {
236
245
  }
237
246
  for (const [inputName, spec] of Object.entries(entry.inputs || {})) {
238
247
  if (spec.type === "substack") continue;
248
+ if (spec.type === "variable" || spec.type === "list") {
249
+ const field = (block.fields || {})[inputName];
250
+ const ref = field ? field[0] : "0";
251
+ args.push(SAFE_VAR_REF.test(ref) ? ref : `"${escapeString(ref)}"`);
252
+ continue;
253
+ }
239
254
  args.push(this.renderExpression(block.inputs[inputName]));
240
255
  }
241
256
  return `${name}(${args.join(", ")})`;
@@ -396,6 +411,18 @@ class Decompiler {
396
411
  option === "timer" ? "timerGreaterThan" : "loudnessGreaterThan";
397
412
  return `${pad}when ${kind}(${this.renderExpression(block.inputs.VALUE)}) {\n${body(block.next)}\n}`;
398
413
  }
414
+ case "event_always":
415
+ return `${pad}when always {\n${body(block.next)}\n}`;
416
+ case "event_whenanything":
417
+ return `${pad}when whenAnything(${this.renderExpression(block.inputs.ANYTHING)}) {\n${body(block.next)}\n}`;
418
+ case "event_whenjavascript":
419
+ return `${pad}when whenJavaScript(${this.renderExpression(block.inputs.JS)}) {\n${body(block.next)}\n}`;
420
+ case "event_whenkeyhit":
421
+ return `${pad}when whenKeyHit("${escapeString((block.fields.KEY_OPTION || ["space"])[0])}") {\n${body(block.next)}\n}`;
422
+ case "event_whenmousescrolled":
423
+ return `${pad}when whenMouseScrolled("${escapeString((block.fields.KEY_OPTION || ["up"])[0])}") {\n${body(block.next)}\n}`;
424
+ case "event_whenstopclicked":
425
+ return `${pad}when whenStopClicked {\n${body(block.next)}\n}`;
399
426
  default:
400
427
  return this.renderExtensionHat(block, pad, body);
401
428
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Terminal output safety.
3
+ *
4
+ * Names, paths and source text can come from a repository the user did not
5
+ * write, so untrusted text is rendered with visible `\xNN` escapes instead of
6
+ * letting the terminal interpret control characters and escape sequences.
7
+ */
8
+
9
+ /**
10
+ * Replace terminal control characters with visible `\xNN` escapes.
11
+ *
12
+ * Tabs and newlines are kept because callers print multi-line blocks.
13
+ * @param {unknown} value
14
+ */
15
+ export function sanitizeTerminalText(value) {
16
+ let clean = "";
17
+ for (const char of String(value)) {
18
+ const code = char.codePointAt(0);
19
+ const printable =
20
+ code === 0x09 ||
21
+ code === 0x0a ||
22
+ (code >= 0x20 && code <= 0x7e) ||
23
+ code >= 0xa0;
24
+ clean += printable ? char : `\\x${code.toString(16).padStart(2, "0")}`;
25
+ }
26
+ return clean;
27
+ }
package/src/vendor.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  readExtensionDataUri,
24
24
  resolveSource,
25
25
  } from "./extensions.js";
26
+ import { sanitizeTerminalText } from "./terminal.js";
26
27
 
27
28
  export const LOCK_FILENAME = "fx.lock";
28
29
  export const LOCK_VERSION = 1;
@@ -1146,13 +1147,14 @@ export async function loadVendorExtensions({
1146
1147
 
1147
1148
  /** A compact line diff, enough to review an extension or config change. */
1148
1149
  export function formatDiff(before, after, filename = "") {
1150
+ const header = sanitizeTerminalText(filename);
1149
1151
  const a = String(before).split(/\r?\n/);
1150
1152
  const b = String(after).split(/\r?\n/);
1151
1153
  const rows = a.length;
1152
1154
  const columns = b.length;
1153
1155
  // Keep very large extensions from allocating an enormous matrix.
1154
1156
  if (rows * columns > 4_000_000) {
1155
- return `--- ${filename}\n+++ ${filename}\n@@ source changed @@\n`;
1157
+ return `--- ${header}\n+++ ${header}\n@@ source changed @@\n`;
1156
1158
  }
1157
1159
  const table = Array.from(
1158
1160
  { length: rows + 1 },
@@ -1166,24 +1168,24 @@ export function formatDiff(before, after, filename = "") {
1166
1168
  : Math.max(table[i + 1][j], table[i][j + 1]);
1167
1169
  }
1168
1170
  }
1169
- const lines = [`--- ${filename}`, `+++ ${filename}`];
1171
+ const lines = [`--- ${header}`, `+++ ${header}`];
1170
1172
  let i = 0;
1171
1173
  let j = 0;
1172
1174
  while (i < rows && j < columns) {
1173
1175
  if (a[i] === b[j]) {
1174
- lines.push(` ${a[i]}`);
1176
+ lines.push(` ${sanitizeTerminalText(a[i])}`);
1175
1177
  i += 1;
1176
1178
  j += 1;
1177
1179
  } else if (table[i + 1][j] >= table[i][j + 1]) {
1178
- lines.push(`-${a[i]}`);
1180
+ lines.push(`-${sanitizeTerminalText(a[i])}`);
1179
1181
  i += 1;
1180
1182
  } else {
1181
- lines.push(`+${b[j]}`);
1183
+ lines.push(`+${sanitizeTerminalText(b[j])}`);
1182
1184
  j += 1;
1183
1185
  }
1184
1186
  }
1185
- while (i < rows) lines.push(`-${a[i++]}`);
1186
- while (j < columns) lines.push(`+${b[j++]}`);
1187
+ while (i < rows) lines.push(`-${sanitizeTerminalText(a[i++])}`);
1188
+ while (j < columns) lines.push(`+${sanitizeTerminalText(b[j++])}`);
1187
1189
  return `${lines.join("\n")}\n`;
1188
1190
  }
1189
1191