@giovannijecha/jecode 0.1.5-rc.2

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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -0
  3. package/bin/jecode.js +11 -0
  4. package/dist/atomic.js +78 -0
  5. package/dist/batch-view.js +17 -0
  6. package/dist/batch.js +100 -0
  7. package/dist/cli-info.js +40 -0
  8. package/dist/commands.js +171 -0
  9. package/dist/config.js +97 -0
  10. package/dist/controller.js +90 -0
  11. package/dist/credential-commands.js +134 -0
  12. package/dist/credential-safety.js +86 -0
  13. package/dist/credentials.js +124 -0
  14. package/dist/main.js +7 -0
  15. package/dist/ollama-settings-command.js +75 -0
  16. package/dist/prompt.js +29 -0
  17. package/dist/provider-commands.js +236 -0
  18. package/dist/provider-errors.js +10 -0
  19. package/dist/providers/anthropic-stream.js +111 -0
  20. package/dist/providers/anthropic-wire.js +79 -0
  21. package/dist/providers/anthropic.js +77 -0
  22. package/dist/providers/catalog.js +37 -0
  23. package/dist/providers/http.js +219 -0
  24. package/dist/providers/index.js +18 -0
  25. package/dist/providers/ollama-endpoint.js +40 -0
  26. package/dist/providers/ollama-stream.js +60 -0
  27. package/dist/providers/ollama-wire.js +95 -0
  28. package/dist/providers/ollama.js +87 -0
  29. package/dist/providers/openai-stream.js +48 -0
  30. package/dist/providers/openai-wire.js +112 -0
  31. package/dist/providers/openai.js +70 -0
  32. package/dist/providers/sse.js +81 -0
  33. package/dist/providers/stream-limits.js +11 -0
  34. package/dist/session.js +3 -0
  35. package/dist/settings-command.js +202 -0
  36. package/dist/settings.js +98 -0
  37. package/dist/start.js +47 -0
  38. package/dist/tools/args.js +38 -0
  39. package/dist/tools/fs.js +277 -0
  40. package/dist/tools/index.js +39 -0
  41. package/dist/tools/paths.js +122 -0
  42. package/dist/tools/search.js +213 -0
  43. package/dist/tools/shell.js +139 -0
  44. package/dist/tools/text-boundary.js +102 -0
  45. package/dist/tools/types.js +1 -0
  46. package/dist/transcript-export.js +11 -0
  47. package/dist/transcript.js +49 -0
  48. package/dist/tui/activity.js +11 -0
  49. package/dist/tui/app-input.js +155 -0
  50. package/dist/tui/app-state.js +17 -0
  51. package/dist/tui/app-workflows.js +105 -0
  52. package/dist/tui/app.js +215 -0
  53. package/dist/tui/approve.js +67 -0
  54. package/dist/tui/blocks.js +23 -0
  55. package/dist/tui/complete.js +54 -0
  56. package/dist/tui/components/command-menu.js +17 -0
  57. package/dist/tui/components/composer.js +47 -0
  58. package/dist/tui/components/dock.js +10 -0
  59. package/dist/tui/components/footer.js +21 -0
  60. package/dist/tui/components/menu.js +38 -0
  61. package/dist/tui/components/messages.js +43 -0
  62. package/dist/tui/components/misc.js +22 -0
  63. package/dist/tui/components/status.js +45 -0
  64. package/dist/tui/components/tool.js +85 -0
  65. package/dist/tui/components/types.js +1 -0
  66. package/dist/tui/editor.js +105 -0
  67. package/dist/tui/feedback.js +74 -0
  68. package/dist/tui/field.js +60 -0
  69. package/dist/tui/frame.js +33 -0
  70. package/dist/tui/input.js +52 -0
  71. package/dist/tui/keys.js +177 -0
  72. package/dist/tui/modal.js +24 -0
  73. package/dist/tui/overlay.js +93 -0
  74. package/dist/tui/picker.js +99 -0
  75. package/dist/tui/screen.js +94 -0
  76. package/dist/tui/scroll.js +7 -0
  77. package/dist/tui/session-view.js +49 -0
  78. package/dist/tui/transcript-view.js +134 -0
  79. package/dist/tui/turn.js +255 -0
  80. package/dist/tui/view.js +88 -0
  81. package/dist/tui/workspace.js +59 -0
  82. package/dist/types.js +9 -0
  83. package/dist/ui/diff.js +109 -0
  84. package/dist/ui/highlight.js +158 -0
  85. package/dist/ui/inline.js +39 -0
  86. package/dist/ui/markdown.js +147 -0
  87. package/dist/ui/render.js +232 -0
  88. package/dist/ui/table.js +127 -0
  89. package/dist/ui/terminal-text.js +42 -0
  90. package/dist/ui/theme.js +25 -0
  91. package/dist/ui/width.js +196 -0
  92. package/dist/usage.js +30 -0
  93. package/dist/user-data.js +29 -0
  94. package/package.json +56 -0
@@ -0,0 +1,93 @@
1
+ // The modal interaction layer: one picker or one single-line field.
2
+ import * as picker from "./picker.js";
3
+ import { oneLine } from "./field.js";
4
+ import { applyKey } from "./input.js";
5
+ export function shown(open) {
6
+ if (open === undefined)
7
+ return undefined;
8
+ return "picker" in open ? { kind: "pick", picker: open.picker } : { kind: "type", field: open.field };
9
+ }
10
+ export function cancel(open) {
11
+ if (open === undefined)
12
+ return undefined;
13
+ open.settle(undefined);
14
+ return undefined;
15
+ }
16
+ export function handle(open, key) {
17
+ if (key.ctrl && key.name === "c") {
18
+ cancel(open);
19
+ return { abort: true };
20
+ }
21
+ if (key.ctrl && key.name === "d") {
22
+ cancel(open);
23
+ return { quit: true };
24
+ }
25
+ if (key.name === "escape") {
26
+ cancel(open);
27
+ return {};
28
+ }
29
+ return "picker" in open ? handlePicker(open, key) : handleField(open, key);
30
+ }
31
+ function handlePicker(open, key) {
32
+ switch (key.name) {
33
+ case "up":
34
+ open.picker = picker.move(open.picker, -1);
35
+ break;
36
+ case "down":
37
+ open.picker = picker.move(open.picker, 1);
38
+ break;
39
+ case "home":
40
+ open.picker = picker.edge(open.picker, "home");
41
+ break;
42
+ case "end":
43
+ open.picker = picker.edge(open.picker, "end");
44
+ break;
45
+ case "pageup":
46
+ open.picker = picker.page(open.picker, -1);
47
+ break;
48
+ case "pagedown":
49
+ open.picker = picker.page(open.picker, 1);
50
+ break;
51
+ case "backspace":
52
+ open.picker = picker.backspace(open.picker);
53
+ break;
54
+ case "enter":
55
+ if (picker.selected(open.picker) === undefined)
56
+ break;
57
+ open.settle(open.picker.index);
58
+ return {};
59
+ case "paste":
60
+ if (open.picker.searchable === true) {
61
+ open.picker = picker.type(open.picker, key.text.replace(/\s+/g, " "));
62
+ }
63
+ break;
64
+ case "char":
65
+ if (open.picker.searchable === true) {
66
+ open.picker = picker.type(open.picker, key.text);
67
+ break;
68
+ }
69
+ {
70
+ const index = picker.byKey(open.picker, key.text);
71
+ if (index === undefined)
72
+ break;
73
+ open.settle(index);
74
+ return {};
75
+ }
76
+ default:
77
+ if (key.ctrl && key.name === "u" && open.picker.searchable === true) {
78
+ open.picker = picker.clear(open.picker);
79
+ }
80
+ }
81
+ return { open };
82
+ }
83
+ function handleField(open, key) {
84
+ if (key.name === "enter") {
85
+ const text = open.field.editor.text.trim();
86
+ open.settle(text === "" ? undefined : text);
87
+ return {};
88
+ }
89
+ const edited = applyKey(open.field.editor, key);
90
+ if (edited !== undefined)
91
+ open.field = { ...open.field, editor: oneLine(edited) };
92
+ return { open };
93
+ }
@@ -0,0 +1,99 @@
1
+ // One interaction model for every terminal selector.
2
+ import { row } from "../ui/render.js";
3
+ import { elide } from "../ui/width.js";
4
+ import { menuWindow, renderMenuRows } from "./components/menu.js";
5
+ const WINDOW = 6;
6
+ export function move(picker, step) {
7
+ const shown = matches(picker);
8
+ if (shown.length === 0)
9
+ return picker;
10
+ const at = Math.max(0, shown.findIndex((entry) => entry.index === picker.index));
11
+ return { ...picker, index: shown[((at + step) % shown.length + shown.length) % shown.length].index };
12
+ }
13
+ export function edge(picker, end) {
14
+ const shown = matches(picker);
15
+ const found = end === "home" ? shown[0] : shown.at(-1);
16
+ return found === undefined ? picker : { ...picker, index: found.index };
17
+ }
18
+ export function page(picker, direction, rows = WINDOW) {
19
+ return move(picker, direction * Math.max(1, rows));
20
+ }
21
+ export function type(picker, text) {
22
+ if (picker.searchable !== true || text === "")
23
+ return picker;
24
+ return withQuery(picker, `${picker.query ?? ""}${text}`);
25
+ }
26
+ export function backspace(picker) {
27
+ if (picker.searchable !== true || (picker.query ?? "") === "")
28
+ return picker;
29
+ return withQuery(picker, Array.from(picker.query ?? "").slice(0, -1).join(""));
30
+ }
31
+ export function clear(picker) {
32
+ return withQuery(picker, "");
33
+ }
34
+ export function selected(picker) {
35
+ return matches(picker).some((entry) => entry.index === picker.index) ? picker.index : undefined;
36
+ }
37
+ export function byKey(picker, text) {
38
+ const typed = text.trim().toLowerCase();
39
+ if (typed === "")
40
+ return undefined;
41
+ const digit = Number(typed);
42
+ if (Number.isInteger(digit) && digit >= 1 && digit <= picker.options.length)
43
+ return digit - 1;
44
+ const found = picker.options.findIndex((option) => option.key === typed);
45
+ return found === -1 ? undefined : found;
46
+ }
47
+ export function panel(picker, width, pal, maxRows = WINDOW + 4) {
48
+ const found = matches(picker);
49
+ const fixed = 2 + (picker.description === undefined ? 0 : 1) + (picker.searchable === true ? 1 : 0);
50
+ const optionRoom = Math.max(1, Math.min(WINDOW, maxRows - fixed));
51
+ const selectedAt = Math.max(0, found.findIndex((entry) => entry.index === picker.index));
52
+ const { first, last } = menuWindow(found.length, selectedAt, optionRoom);
53
+ const shown = found.slice(first, last);
54
+ const options = shown.length === 0
55
+ ? [row(width, [{ text: " no matches", fg: pal.ink.muted }])]
56
+ : renderMenuRows(shown.map(({ option, index }) => ({
57
+ label: option.label,
58
+ hint: option.hint,
59
+ selected: index === picker.index,
60
+ })), width, pal);
61
+ const progress = found.length > shown.length || picker.searchable === true
62
+ ? `${found.length === 0 ? "0" : `${first + 1}–${first + shown.length}`} / ${found.length}` +
63
+ (found.length === picker.options.length ? "" : ` · ${picker.options.length} total`) + " · "
64
+ : "";
65
+ const footer = picker.footer ?? "Enter to select · Esc to close";
66
+ return [
67
+ row(width, picker.title, picker.right === undefined ? [] : [{ text: picker.right, fg: pal.ink.muted }]),
68
+ ...(picker.description === undefined
69
+ ? []
70
+ : [row(width, [{ text: picker.description, fg: pal.ink.muted }])]),
71
+ ...(picker.searchable === true
72
+ ? [
73
+ row(width, [
74
+ { text: " filter ", fg: pal.ink.muted },
75
+ { text: picker.query === "" || picker.query === undefined ? "type to search" : picker.query, fg: pal.ink.bright },
76
+ ]),
77
+ ]
78
+ : []),
79
+ ...options,
80
+ row(width, [{ text: ` ${elide(progress + footer, Math.max(1, width - 2))}`, fg: pal.ink.muted }]),
81
+ ];
82
+ }
83
+ export function heading(label, about, pal) {
84
+ return [
85
+ { text: `${label} `, fg: pal.accent, bold: true },
86
+ { text: about, fg: pal.ink.fg },
87
+ ];
88
+ }
89
+ function matches(picker) {
90
+ const query = (picker.query ?? "").trim().toLocaleLowerCase();
91
+ return picker.options.flatMap((option, index) => {
92
+ const haystack = `${option.label} ${option.hint ?? ""}`.toLocaleLowerCase();
93
+ return query === "" || haystack.includes(query) ? [{ option, index }] : [];
94
+ });
95
+ }
96
+ function withQuery(picker, query) {
97
+ const next = { ...picker, query };
98
+ return { ...next, index: matches(next)[0]?.index ?? 0 };
99
+ }
@@ -0,0 +1,94 @@
1
+ // The terminal, taken over and given back.
2
+ //
3
+ // A TUI owns the screen, and every takeover has to be undone — on a clean
4
+ // exit, on a crash, on a signal. The restore is registered before the first
5
+ // escape is written, never after: a process that dies holding raw mode leaves
6
+ // the user with a shell that does not echo.
7
+ import { CSI } from "../ui/render.js";
8
+ const ALT_ON = `${CSI}?1049h`;
9
+ const ALT_OFF = `${CSI}?1049l`;
10
+ const CURSOR_HIDE = `${CSI}?25l`;
11
+ const CURSOR_SHOW = `${CSI}?25h`;
12
+ const PASTE_ON = `${CSI}?2004h`;
13
+ const PASTE_OFF = `${CSI}?2004l`;
14
+ // Auto-wrap off: every row is positioned absolutely and painted to the full
15
+ // width, and a full-width write on the last row would otherwise scroll the
16
+ // screen out from under the frame.
17
+ const WRAP_OFF = `${CSI}?7l`;
18
+ const WRAP_ON = `${CSI}?7h`;
19
+ // Button events without drag (?1000) reported in SGR form (?1006). Wheel events
20
+ // come with them, and the wheel is the reason to ask: a transcript that can
21
+ // only be scrolled by keyboard reads as a transcript that cannot be scrolled.
22
+ const MOUSE_ON = `${CSI}?1000h${CSI}?1006h`;
23
+ const MOUSE_OFF = `${CSI}?1006l${CSI}?1000l`;
24
+ // The caret, as a blinking filled cell rather than a hairline (DECSCUSR). A
25
+ // bar is the default because a bar is what a text field wants; this is not a
26
+ // text field, it is a screen jecode drew, and the block is the only mark on it
27
+ // wide enough to find at a glance. Blinking on purpose: on a screen with no
28
+ // other chrome around the input, a steady block is one more static glyph, and
29
+ // motion is what says the thing is waiting for you. `0` puts the terminal back
30
+ // on whatever the user chose.
31
+ const CURSOR_BLOCK = `${CSI}1 q`;
32
+ const CURSOR_STEADY = `${CSI}2 q`;
33
+ const CURSOR_RESET = `${CSI}0 q`;
34
+ // Synchronized output: the terminal is told to hold the screen until the frame
35
+ // is complete. Without it a repaint mid-refresh is drawn half-old and
36
+ // half-new, which is what tearing during a stream actually is.
37
+ const SYNC_BEGIN = `${CSI}?2026h`;
38
+ const SYNC_END = `${CSI}?2026l`;
39
+ let active = false;
40
+ export function interactive() {
41
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
42
+ }
43
+ export function size() {
44
+ return {
45
+ rows: Math.max(1, process.stdout.rows ?? 24),
46
+ cols: Math.max(1, process.stdout.columns ?? 80),
47
+ };
48
+ }
49
+ export function write(text) {
50
+ process.stdout.write(text);
51
+ }
52
+ export function enter(reducedMotion = false) {
53
+ if (active)
54
+ return;
55
+ active = true;
56
+ process.on("exit", leave);
57
+ process.on("SIGTERM", onFatalSignal);
58
+ process.on("SIGHUP", onFatalSignal);
59
+ write(ALT_ON + WRAP_OFF + CURSOR_HIDE + (reducedMotion ? CURSOR_STEADY : CURSOR_BLOCK) + PASTE_ON + MOUSE_ON);
60
+ process.stdin.setRawMode(true);
61
+ process.stdin.setEncoding("utf8");
62
+ process.stdin.resume();
63
+ }
64
+ export function leave() {
65
+ if (!active)
66
+ return;
67
+ active = false;
68
+ if (process.stdin.isTTY === true)
69
+ process.stdin.setRawMode(false);
70
+ process.stdin.pause();
71
+ write(MOUSE_OFF + PASTE_OFF + CURSOR_RESET + CURSOR_SHOW + WRAP_ON + ALT_OFF);
72
+ }
73
+ export function setReducedMotion(reducedMotion) {
74
+ if (active)
75
+ write(reducedMotion ? CURSOR_STEADY : CURSOR_BLOCK);
76
+ }
77
+ function onFatalSignal() {
78
+ leave();
79
+ process.exit(0);
80
+ }
81
+ export function onResize(handler) {
82
+ process.stdout.on("resize", handler);
83
+ return () => {
84
+ process.stdout.off("resize", handler);
85
+ };
86
+ }
87
+ export function onInput(handler) {
88
+ process.stdin.on("data", handler);
89
+ return () => {
90
+ process.stdin.off("data", handler);
91
+ };
92
+ }
93
+ export const CURSOR = { hide: CURSOR_HIDE, show: CURSOR_SHOW };
94
+ export const SYNC = { begin: SYNC_BEGIN, end: SYNC_END };
@@ -0,0 +1,7 @@
1
+ // Scroll offsets are measured from the bottom. Preserve what is being read as
2
+ // the transcript grows, while offset zero keeps following new output.
3
+ export function preserveOffset(offset, following, previousMax, nextMax) {
4
+ if (following)
5
+ return 0;
6
+ return Math.min(nextMax, Math.max(0, offset + Math.max(0, nextMax - previousMax)));
7
+ }
@@ -0,0 +1,49 @@
1
+ // Small projections of the live session used by the shell and footer.
2
+ import { credentialSource } from "../credentials.js";
3
+ import { providerFailure } from "../provider-errors.js";
4
+ export function controllerOptions(session) {
5
+ return {
6
+ provider: session.provider,
7
+ tools: session.tools,
8
+ model: session.model,
9
+ system: session.system,
10
+ maxTokens: session.config.maxTokens,
11
+ effort: session.config.effort,
12
+ maxSteps: session.config.maxSteps,
13
+ toolContext: { root: session.config.root },
14
+ };
15
+ }
16
+ export function footerInfo(session, workspace = session.config.root) {
17
+ return {
18
+ workspace,
19
+ model: session.model || "no model",
20
+ effort: session.config.effort,
21
+ };
22
+ }
23
+ /** One transcript event for a real turn failure, including actionable auth guidance. */
24
+ export function turnFailure(session, error, aborted) {
25
+ if (aborted)
26
+ return { kind: "notice", text: "[interrupted]", tone: "warn" };
27
+ let text = providerFailure(session.provider, error);
28
+ if (/\b401\b/.test(text)) {
29
+ const source = credentialSource(session.provider.keyVar);
30
+ text += source === "environment"
31
+ ? ` · update ${session.provider.keyVar} in the environment and restart`
32
+ : " · check credentials with /settings";
33
+ }
34
+ return { kind: "notice", text, tone: "error" };
35
+ }
36
+ export function toggleDetails(blocks) {
37
+ for (let index = blocks.length - 1; index >= 0; index--) {
38
+ const block = blocks[index];
39
+ if (block?.kind === "tool" && (block.body?.length ?? 0) > 0) {
40
+ block.expanded = block.expanded !== true;
41
+ return block;
42
+ }
43
+ if (block?.kind === "reasoning") {
44
+ block.expanded = block.expanded !== true;
45
+ return block;
46
+ }
47
+ }
48
+ return undefined;
49
+ }
@@ -0,0 +1,134 @@
1
+ // Incremental transcript layout for long-running sessions.
2
+ import { render } from "./blocks.js";
3
+ /**
4
+ * Keep block rows and cumulative row ends between frames.
5
+ *
6
+ * The app mutates semantic blocks while streaming, so it marks that one block
7
+ * dirty. Appends then cost one render, stable frames cost no block renders,
8
+ * and viewport selection starts with a binary search instead of flattening or
9
+ * scanning the complete transcript.
10
+ */
11
+ export function transcriptRenderer(draw = render) {
12
+ let source;
13
+ let layoutWidth = -1;
14
+ let layoutPalette;
15
+ let entries = [];
16
+ let ends = [];
17
+ let positions = new WeakMap();
18
+ const dirty = new Set();
19
+ return {
20
+ viewport(blocks, width, height, scroll, palette) {
21
+ sync(blocks, width, palette);
22
+ const visibleHeight = Math.max(0, height);
23
+ const totalRows = ends.at(-1) ?? 0;
24
+ const maxScroll = Math.max(0, totalRows - visibleHeight);
25
+ const offset = Math.min(Math.max(0, scroll), maxScroll);
26
+ const start = Math.max(0, totalRows - visibleHeight - offset);
27
+ const end = start + visibleHeight;
28
+ const rows = [];
29
+ let index = firstEndingAfter(start);
30
+ let at = index === 0 ? 0 : (ends[index - 1] ?? 0);
31
+ while (index < entries.length && at < end) {
32
+ const blockRows = entries[index]?.rows ?? [];
33
+ rows.push(...blockRows.slice(Math.max(0, start - at), Math.min(blockRows.length, end - at)));
34
+ at = ends[index] ?? at;
35
+ index++;
36
+ }
37
+ while (rows.length < visibleHeight)
38
+ rows.unshift("");
39
+ return { rows, maxScroll };
40
+ },
41
+ invalidate(block) {
42
+ if (block !== undefined) {
43
+ dirty.add(block);
44
+ return;
45
+ }
46
+ source = undefined;
47
+ entries = [];
48
+ ends = [];
49
+ positions = new WeakMap();
50
+ dirty.clear();
51
+ },
52
+ };
53
+ function sync(blocks, width, palette) {
54
+ if (source !== blocks ||
55
+ layoutWidth !== width ||
56
+ layoutPalette !== palette ||
57
+ blocks.length < entries.length ||
58
+ edgeChanged(blocks)) {
59
+ rebuild(blocks, width, palette);
60
+ return;
61
+ }
62
+ append(blocks, width, palette);
63
+ refreshDirty(width, palette);
64
+ }
65
+ function rebuild(blocks, width, palette) {
66
+ source = blocks;
67
+ layoutWidth = width;
68
+ layoutPalette = palette;
69
+ entries = [];
70
+ ends = [];
71
+ positions = new WeakMap();
72
+ dirty.clear();
73
+ append(blocks, width, palette);
74
+ }
75
+ function append(blocks, width, palette) {
76
+ let total = ends.at(-1) ?? 0;
77
+ for (let index = entries.length; index < blocks.length; index++) {
78
+ const block = blocks[index];
79
+ if (block === undefined)
80
+ continue;
81
+ const rows = draw(block, width, palette);
82
+ entries.push({ block, rows });
83
+ positions.set(block, index);
84
+ dirty.delete(block);
85
+ total += rows.length;
86
+ ends.push(total);
87
+ }
88
+ }
89
+ function refreshDirty(width, palette) {
90
+ if (dirty.size === 0)
91
+ return;
92
+ const changed = [...dirty]
93
+ .map((block) => positions.get(block))
94
+ .filter((index) => index !== undefined)
95
+ .sort((left, right) => left - right);
96
+ dirty.clear();
97
+ if (changed.length === 0)
98
+ return;
99
+ const first = changed[0];
100
+ for (const index of changed) {
101
+ const entry = entries[index];
102
+ if (entry !== undefined)
103
+ entry.rows = draw(entry.block, width, palette);
104
+ }
105
+ recomputeEnds(first);
106
+ }
107
+ function recomputeEnds(from) {
108
+ let total = from === 0 ? 0 : (ends[from - 1] ?? 0);
109
+ for (let index = from; index < entries.length; index++) {
110
+ total += entries[index]?.rows.length ?? 0;
111
+ ends[index] = total;
112
+ }
113
+ }
114
+ function edgeChanged(blocks) {
115
+ if (entries.length === 0)
116
+ return false;
117
+ const lastShared = Math.min(blocks.length, entries.length) - 1;
118
+ return lastShared < 0 ||
119
+ entries[0]?.block !== blocks[0] ||
120
+ entries[lastShared]?.block !== blocks[lastShared];
121
+ }
122
+ function firstEndingAfter(row) {
123
+ let low = 0;
124
+ let high = ends.length;
125
+ while (low < high) {
126
+ const middle = Math.floor((low + high) / 2);
127
+ if ((ends[middle] ?? 0) <= row)
128
+ low = middle + 1;
129
+ else
130
+ high = middle;
131
+ }
132
+ return low;
133
+ }
134
+ }