@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,255 @@
1
+ // Turning what the controller reports into what the transcript shows.
2
+ //
3
+ // The controller speaks in stream events and tool results; the screen speaks in
4
+ // blocks. This is the whole of the translation, kept out of the shell so that
5
+ // neither has to know how the other is built.
6
+ import { condense, diff } from "../ui/diff.js";
7
+ import { promptFor } from "./approve.js";
8
+ /**
9
+ * What the footer can truthfully say.
10
+ *
11
+ * Every one of these is read off an event that has actually arrived, never
12
+ * from a guess about where the turn probably is. A status line that says one
13
+ * thing for the whole turn is decoration; this one is the only window into a
14
+ * process that is otherwise silent for seconds at a time, so it has to be
15
+ * worth believing.
16
+ */
17
+ const WAITING = "Waiting";
18
+ const THINKING = "Thinking";
19
+ const WRITING = "Writing";
20
+ const ASKING = "Waiting for you";
21
+ /** Unchanged rows kept either side of a change. */
22
+ const CONTEXT = 2;
23
+ export function transcribe(stage) {
24
+ // The block the stream is currently filling. A change of kind starts a new
25
+ // one, which is what keeps reasoning and answer from running together.
26
+ let open;
27
+ const tools = new Map();
28
+ let step = 1;
29
+ let steps = 1;
30
+ let tool = 1;
31
+ let toolTotal = 1;
32
+ const close = () => {
33
+ const block = open?.block;
34
+ const changed = block?.kind === "reasoning" && block.live === true;
35
+ if (changed && block.kind === "reasoning")
36
+ block.live = false;
37
+ open = undefined;
38
+ return changed ? block : undefined;
39
+ };
40
+ return {
41
+ finish() {
42
+ const changed = close();
43
+ if (changed !== undefined)
44
+ stage.render(changed);
45
+ },
46
+ onStep(current, total) {
47
+ step = current;
48
+ steps = total;
49
+ stage.status(waiting(step, steps));
50
+ stage.render();
51
+ },
52
+ onToolProgress(current, total) {
53
+ tool = current;
54
+ toolTotal = total;
55
+ },
56
+ onUsage(usage) {
57
+ stage.usage?.(usage);
58
+ },
59
+ onStatus(status) {
60
+ stage.status(status);
61
+ stage.render();
62
+ },
63
+ onStream(event) {
64
+ const kind = event.kind === "thinking" ? "reasoning" : "answer";
65
+ stage.status(kind === "reasoning" ? THINKING : WRITING);
66
+ if (open === undefined || open.kind !== kind) {
67
+ const changed = close();
68
+ if (changed !== undefined)
69
+ stage.render(changed);
70
+ const block = kind === "reasoning"
71
+ ? { kind, text: "", live: true, expanded: false }
72
+ : { kind, text: "" };
73
+ open = { kind, block };
74
+ stage.emit(block);
75
+ }
76
+ if (open.block.kind === "answer" || open.block.kind === "reasoning") {
77
+ open.block.text += event.text;
78
+ }
79
+ stage.render(open.block);
80
+ },
81
+ onToolCall(call, look) {
82
+ const changed = close();
83
+ if (changed !== undefined)
84
+ stage.render(changed);
85
+ stage.status(`Running ${call.name}${toolTotal > 1 ? ` · tool ${tool}/${toolTotal}` : ""}${step > 1 ? ` · step ${step}/${steps}` : ""}`);
86
+ const block = {
87
+ kind: "tool",
88
+ name: call.name,
89
+ target: target(call.input),
90
+ right: "pending",
91
+ tone: "pending",
92
+ body: preview(call, look),
93
+ };
94
+ tools.set(call.id, block);
95
+ stage.emit(block);
96
+ stage.render(block);
97
+ },
98
+ onToolResult(call, result, summary) {
99
+ stage.status(waiting(step, steps));
100
+ const block = tools.get(call.id);
101
+ if (block === undefined || block.kind !== "tool")
102
+ return;
103
+ block.tone = result.isError ? "fail" : "ok";
104
+ block.right = summary ?? "";
105
+ // A failure replaces the preview: what the call was going to do stops
106
+ // being the interesting part the moment it did not do it.
107
+ const outcome = result.isError
108
+ ? details(result.output, "out")
109
+ : produced(call, result.output);
110
+ if (outcome !== undefined)
111
+ block.body = outcome;
112
+ stage.render(block);
113
+ },
114
+ approve(call) {
115
+ if (stage.approved(call))
116
+ return Promise.resolve(true);
117
+ const changed = close();
118
+ if (changed !== undefined)
119
+ stage.render(changed);
120
+ stage.status(ASKING);
121
+ return new Promise((resolve) => {
122
+ stage.ask(promptFor(call, target(call.input), stage.palette), (answer) => {
123
+ if (answer === "always")
124
+ stage.remember(call);
125
+ const approved = answer !== "no";
126
+ const block = tools.get(call.id);
127
+ if (!approved && block !== undefined && block.kind === "tool") {
128
+ block.tone = "deny";
129
+ block.right = "declined";
130
+ block.body = undefined;
131
+ }
132
+ stage.status(approved ? `Running ${call.name}` : waiting(step, steps));
133
+ stage.render(block);
134
+ resolve(approved);
135
+ });
136
+ });
137
+ },
138
+ };
139
+ }
140
+ function waiting(step, total) {
141
+ return step === 1 ? WAITING : `${WAITING} · step ${step}/${total}`;
142
+ }
143
+ /** The argument worth showing: the thing the call acts on. */
144
+ function target(input) {
145
+ const { path, command } = input;
146
+ if (typeof command === "string")
147
+ return command;
148
+ if (typeof path === "string")
149
+ return path;
150
+ const rest = JSON.stringify(input);
151
+ return rest === "{}" ? "" : rest;
152
+ }
153
+ /**
154
+ * What the call is about to do, drawn before it is allowed to do it.
155
+ *
156
+ * The tool is asked first, because only it knows what is already on disk: a
157
+ * write against an existing file is a replacement, and showing it as a page of
158
+ * additions hides exactly the part worth approving. The fallback diffs what is
159
+ * in the arguments, which is all there is when a tool has nothing to say.
160
+ */
161
+ function preview(call, look) {
162
+ if (look !== undefined)
163
+ return changes(look.before, look.after);
164
+ const input = call.input;
165
+ if (call.name === "write_file" && typeof input.content === "string") {
166
+ return changes("", input.content);
167
+ }
168
+ if (call.name === "edit_file" && typeof input.old_text === "string") {
169
+ return changes(input.old_text, typeof input.new_text === "string" ? input.new_text : "");
170
+ }
171
+ return undefined;
172
+ }
173
+ /** Two texts as the rows of their difference, unchanged runs summed up. */
174
+ function changes(before, after) {
175
+ let oldLine = 1;
176
+ let newLine = 1;
177
+ const rows = [];
178
+ for (const changed of condense(diff(before, after), CONTEXT)) {
179
+ if (changed.kind === "gap") {
180
+ rows.push({ kind: "gap", text: `… ${changed.skipped} unchanged` });
181
+ oldLine += changed.skipped;
182
+ newLine += changed.skipped;
183
+ continue;
184
+ }
185
+ if (changed.kind === "keep") {
186
+ rows.push({ kind: "keep", text: tabs(changed.text), oldLine, newLine });
187
+ oldLine++;
188
+ newLine++;
189
+ continue;
190
+ }
191
+ if (changed.kind === "del") {
192
+ rows.push({ kind: "del", text: tabs(changed.text), oldLine });
193
+ oldLine++;
194
+ continue;
195
+ }
196
+ rows.push({ kind: "add", text: tabs(changed.text), newLine });
197
+ newLine++;
198
+ }
199
+ emphasizePairs(rows);
200
+ return rows.length === 0 ? undefined : rows;
201
+ }
202
+ function emphasizePairs(rows) {
203
+ for (let index = 0; index < rows.length - 1; index++) {
204
+ const removed = rows[index];
205
+ const added = rows[index + 1];
206
+ if (removed?.kind !== "del" || added?.kind !== "add")
207
+ continue;
208
+ if (rows[index - 1]?.kind === "del" || rows[index + 2]?.kind === "add")
209
+ continue;
210
+ let start = 0;
211
+ while (start < removed.text.length && start < added.text.length && removed.text[start] === added.text[start])
212
+ start++;
213
+ let suffix = 0;
214
+ while (suffix < removed.text.length - start &&
215
+ suffix < added.text.length - start &&
216
+ removed.text[removed.text.length - 1 - suffix] === added.text[added.text.length - 1 - suffix])
217
+ suffix++;
218
+ while (suffix > 0 && (!wordBoundary(removed.text, suffix) || !wordBoundary(added.text, suffix)))
219
+ suffix--;
220
+ const removedLength = removed.text.length - start - suffix;
221
+ const addedLength = added.text.length - start - suffix;
222
+ if (removedLength > 0)
223
+ removed.emphasis = { start, length: removedLength };
224
+ if (addedLength > 0)
225
+ added.emphasis = { start, length: addedLength };
226
+ }
227
+ }
228
+ function wordBoundary(text, suffix) {
229
+ const at = text.length - suffix;
230
+ if (at <= 0 || at >= text.length)
231
+ return true;
232
+ return /\w/.test(text[at - 1]) !== /\w/.test(text[at]);
233
+ }
234
+ /** What the call left behind, for the calls whose output is worth a look. */
235
+ function produced(call, output) {
236
+ if (call.name === "run_command")
237
+ return details(output, "out");
238
+ // A read or a listing is already summed up on the right of its own row, and
239
+ // the model is about to say what was in it. Printing it twice helps nobody.
240
+ return undefined;
241
+ }
242
+ function details(text, kind) {
243
+ const rows = split(text).map((line) => ({ kind, text: tabs(line) }));
244
+ return rows.length === 0 ? undefined : rows;
245
+ }
246
+ function split(text) {
247
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
248
+ while (lines.length > 0 && lines[lines.length - 1] === "")
249
+ lines.pop();
250
+ return lines.map(tabs);
251
+ }
252
+ // A tab is a width the terminal decides and the row measurement cannot see.
253
+ function tabs(line) {
254
+ return line.replace(/\t/g, " ");
255
+ }
@@ -0,0 +1,88 @@
1
+ // Compose one exact-height frame from the transcript and production chrome.
2
+ import { row } from "../ui/render.js";
3
+ import { elide } from "../ui/width.js";
4
+ import * as modal from "./modal.js";
5
+ import { renderCommandMenu } from "./components/command-menu.js";
6
+ import { renderComposer } from "./components/composer.js";
7
+ import { renderDock } from "./components/dock.js";
8
+ import { renderFooter } from "./components/footer.js";
9
+ import { renderStatus, spinner } from "./components/status.js";
10
+ import { transcriptRenderer } from "./transcript-view.js";
11
+ export { spinner } from "./components/status.js";
12
+ const DOCK_MAX = 6;
13
+ const FOOTER_ROWS = 1;
14
+ const DOCK_RULES = 2;
15
+ const TRANSCRIPT_DOCK_GAP = 1;
16
+ const TRANSCRIPT_MIN = 1;
17
+ const MIN_ROWS = 14;
18
+ const MIN_COLS = 38;
19
+ export function compose(view, size, transcript = transcriptRenderer()) {
20
+ const height = Math.max(1, size.rows);
21
+ const width = Math.max(1, size.cols);
22
+ if (height < MIN_ROWS || width < MIN_COLS)
23
+ return tooSmall(height, width, view);
24
+ const dock = dockRows(view, width, height);
25
+ const transcriptHeight = Math.max(1, height - dock.rows.length);
26
+ // Any spare height belongs above the conversation, so short sessions grow
27
+ // upward from the fixed dock rhythm instead of leaving a changing hole
28
+ // beneath the latest reply.
29
+ const viewport = transcript.viewport(view.blocks, width, transcriptHeight, view.scroll, view.pal);
30
+ const cursor = dock.cursor === undefined
31
+ ? undefined
32
+ : { row: transcriptHeight + dock.cursor.row, col: dock.cursor.col };
33
+ return { rows: [...viewport.rows, ...dock.rows], cursor, maxScroll: viewport.maxScroll };
34
+ }
35
+ function dockRows(view, width, height) {
36
+ const status = renderStatus({
37
+ // A modal already names the interaction in progress. Let command feedback
38
+ // use the footer while that interaction is open instead of repeating a
39
+ // generic "Running /…" label beside it.
40
+ status: view.modal === undefined ? view.status : undefined,
41
+ feedback: view.feedback,
42
+ readiness: view.readiness,
43
+ tick: view.spin,
44
+ reducedMotion: view.reducedMotion === true,
45
+ unseen: view.unseen ?? 0,
46
+ }, view.pal);
47
+ const footer = renderFooter(view.footer, status, width, view.pal);
48
+ if (view.modal !== undefined) {
49
+ const body = {
50
+ rows: modal.panel(view.modal, width, view.pal, height - FOOTER_ROWS - TRANSCRIPT_MIN - TRANSCRIPT_DOCK_GAP - DOCK_RULES),
51
+ cursor: modal.caret(view.modal, width),
52
+ };
53
+ const dock = renderDock(body, width, view.pal, true);
54
+ return {
55
+ rows: ["", ...dock.rows, ...footer],
56
+ cursor: offset(dock.cursor, TRANSCRIPT_DOCK_GAP),
57
+ };
58
+ }
59
+ const menu = renderCommandMenu(view.menu ?? [], view.menuIndex, width, view.pal);
60
+ const available = height
61
+ - TRANSCRIPT_MIN
62
+ - TRANSCRIPT_DOCK_GAP
63
+ - menu.length
64
+ - FOOTER_ROWS
65
+ - DOCK_RULES;
66
+ const composer = renderComposer(view.editor, width, Math.max(1, Math.min(DOCK_MAX, available)), view.pal);
67
+ const dock = renderDock({ rows: [...composer.rows, ...menu], cursor: composer.cursor }, width, view.pal, menu.length > 0);
68
+ return {
69
+ rows: ["", ...dock.rows, ...footer],
70
+ cursor: offset(dock.cursor, TRANSCRIPT_DOCK_GAP),
71
+ };
72
+ }
73
+ function offset(cursor, rows) {
74
+ return cursor === undefined ? undefined : { row: cursor.row + rows, col: cursor.col };
75
+ }
76
+ function tooSmall(height, width, view) {
77
+ const rows = Array.from({ length: height }, () => "");
78
+ const middle = Math.floor(height / 2);
79
+ rows[middle] = row(width, [
80
+ { text: elide("jecode · terminal too small", Math.max(1, width)), fg: view.pal.accent, bold: true },
81
+ ]);
82
+ if (middle + 1 < height) {
83
+ rows[middle + 1] = row(width, [
84
+ { text: elide(`need ${MIN_COLS}×${MIN_ROWS}`, Math.max(1, width)), fg: view.pal.ink.muted },
85
+ ]);
86
+ }
87
+ return { rows, maxScroll: 0 };
88
+ }
@@ -0,0 +1,59 @@
1
+ // Compact workspace identity for the persistent footer.
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import * as path from "node:path";
5
+ export async function workspaceLabel(root) {
6
+ const shown = displayRoot(root);
7
+ const branch = await findBranch(root);
8
+ return branch === undefined ? shown : `${shown} (${branch})`;
9
+ }
10
+ export function displayRoot(root, home = homedir()) {
11
+ const resolvedHome = path.resolve(home);
12
+ const absolute = path.resolve(root);
13
+ const relative = path.relative(resolvedHome, absolute);
14
+ const inside = relative === "" ||
15
+ (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
16
+ return inside ? (relative === "" ? "~" : `~${path.sep}${relative}`) : absolute;
17
+ }
18
+ async function findBranch(root) {
19
+ let directory = path.resolve(root);
20
+ for (;;) {
21
+ const branch = await branchAt(directory);
22
+ if (branch !== undefined)
23
+ return branch;
24
+ const parent = path.dirname(directory);
25
+ if (parent === directory)
26
+ return undefined;
27
+ directory = parent;
28
+ }
29
+ }
30
+ async function branchAt(directory) {
31
+ const marker = path.join(directory, ".git");
32
+ try {
33
+ const info = await stat(marker);
34
+ if (info.isDirectory())
35
+ return readHead(marker);
36
+ if (!info.isFile())
37
+ return undefined;
38
+ const pointer = await readFile(marker, "utf8");
39
+ const match = /^gitdir:\s*(.+)\s*$/im.exec(pointer);
40
+ if (match === null)
41
+ return undefined;
42
+ return readHead(path.resolve(directory, match[1]));
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ async function readHead(gitDirectory) {
49
+ try {
50
+ const head = (await readFile(path.join(gitDirectory, "HEAD"), "utf8")).trim();
51
+ const prefix = "ref: refs/heads/";
52
+ if (head.startsWith(prefix))
53
+ return head.slice(prefix.length);
54
+ return /^[0-9a-f]{7,}$/i.test(head) ? head.slice(0, 7) : undefined;
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ }
package/dist/types.js ADDED
@@ -0,0 +1,9 @@
1
+ // The normalized vocabulary the controller speaks. Deliberately smaller than
2
+ // any vendor's wire format: text, a tool call, a tool result. Providers
3
+ // translate to and from this; the controller never sees a vendor shape.
4
+ export function isToolCall(block) {
5
+ return block.kind === "tool_call";
6
+ }
7
+ export function isText(block) {
8
+ return block.kind === "text";
9
+ }
@@ -0,0 +1,109 @@
1
+ // Comparing two texts line by line.
2
+ //
3
+ // Written here rather than shelled out to `git diff`, because a tool result has
4
+ // to be drawable on a machine with no git, in a workspace that is not a repo,
5
+ // for text that was never on disk — the new half of an edit exists only in the
6
+ // call the model just made.
7
+ /**
8
+ * Above this many cells the table stops being worth building.
9
+ *
10
+ * The diff is quadratic in the two line counts, and it is drawn between two
11
+ * keystrokes. Past the ceiling the honest answer is the coarse one — all of
12
+ * the old, then all of the new — rather than a frame the user waits for.
13
+ */
14
+ const CEILING = 250_000;
15
+ export function diff(before, after) {
16
+ const a = lines(before);
17
+ const b = lines(after);
18
+ if (a.length * b.length > CEILING) {
19
+ return [...a.map(del), ...b.map(add)];
20
+ }
21
+ const table = common(a, b);
22
+ const rows = [];
23
+ let i = 0;
24
+ let j = 0;
25
+ const width = b.length + 1;
26
+ while (i < a.length && j < b.length) {
27
+ if (a[i] === b[j]) {
28
+ rows.push({ kind: "keep", text: a[i] });
29
+ i++;
30
+ j++;
31
+ continue;
32
+ }
33
+ // Deletions first on a tie, so a replaced line reads old-then-new — the
34
+ // order the eye expects, and the order every other diff prints.
35
+ if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
36
+ rows.push(del(a[i]));
37
+ i++;
38
+ }
39
+ else {
40
+ rows.push(add(b[j]));
41
+ j++;
42
+ }
43
+ }
44
+ while (i < a.length)
45
+ rows.push(del(a[i++]));
46
+ while (j < b.length)
47
+ rows.push(add(b[j++]));
48
+ return rows;
49
+ }
50
+ /**
51
+ * Drop the unchanged stretches, keeping `context` rows either side.
52
+ *
53
+ * A diff nobody can see the changes in is a wall of text with a colour in it.
54
+ */
55
+ export function condense(rows, context) {
56
+ const near = new Set();
57
+ rows.forEach((row, index) => {
58
+ if (row.kind === "keep")
59
+ return;
60
+ for (let k = index - context; k <= index + context; k++)
61
+ near.add(k);
62
+ });
63
+ const out = [];
64
+ let skipped = 0;
65
+ const flush = () => {
66
+ if (skipped > 0)
67
+ out.push({ kind: "gap", skipped });
68
+ skipped = 0;
69
+ };
70
+ rows.forEach((row, index) => {
71
+ if (near.has(index)) {
72
+ flush();
73
+ out.push(row);
74
+ }
75
+ else {
76
+ skipped++;
77
+ }
78
+ });
79
+ flush();
80
+ return out;
81
+ }
82
+ /** Lengths of the longest common subsequence of every pair of suffixes. */
83
+ function common(a, b) {
84
+ const width = b.length + 1;
85
+ const table = new Uint32Array((a.length + 1) * width);
86
+ for (let i = a.length - 1; i >= 0; i--) {
87
+ for (let j = b.length - 1; j >= 0; j--) {
88
+ table[i * width + j] =
89
+ a[i] === b[j]
90
+ ? table[(i + 1) * width + j + 1] + 1
91
+ : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
92
+ }
93
+ }
94
+ return table;
95
+ }
96
+ // A trailing newline is a property of the file, not a line of it: counting it
97
+ // as one would report every append as touching two lines instead of one.
98
+ function lines(text) {
99
+ const split = text.split("\n");
100
+ if (split.length > 1 && split[split.length - 1] === "")
101
+ split.pop();
102
+ return split;
103
+ }
104
+ function add(text) {
105
+ return { kind: "add", text };
106
+ }
107
+ function del(text) {
108
+ return { kind: "del", text };
109
+ }
@@ -0,0 +1,158 @@
1
+ // Colouring a fenced code block.
2
+ //
3
+ // Not a parser and not trying to be one: a code block on screen is read, not
4
+ // compiled, and what a reader needs is the shape — where the strings end, what
5
+ // is a comment, which words are the language's own. Five roles carry all of
6
+ // that, and a language nobody here knows gets no colour at all rather than
7
+ // colour that is wrong, because wrong highlighting reads as a wrong program.
8
+ const FAMILIES = {
9
+ ts: "clike",
10
+ tsx: "clike",
11
+ typescript: "clike",
12
+ js: "clike",
13
+ jsx: "clike",
14
+ javascript: "clike",
15
+ mjs: "clike",
16
+ java: "clike",
17
+ c: "clike",
18
+ cpp: "clike",
19
+ cs: "clike",
20
+ go: "clike",
21
+ rust: "clike",
22
+ rs: "clike",
23
+ swift: "clike",
24
+ php: "clike",
25
+ py: "python",
26
+ python: "python",
27
+ sh: "shell",
28
+ bash: "shell",
29
+ zsh: "shell",
30
+ shell: "shell",
31
+ console: "shell",
32
+ ps1: "shell",
33
+ powershell: "shell",
34
+ json: "json",
35
+ jsonc: "json",
36
+ };
37
+ const KEYWORDS = {
38
+ clike: new Set([
39
+ "abstract", "as", "async", "await", "break", "case", "catch", "class", "const",
40
+ "continue", "declare", "default", "delete", "do", "else", "enum", "export",
41
+ "extends", "false", "finally", "for", "from", "func", "function", "get", "if",
42
+ "implements", "import", "in", "instanceof", "interface", "let", "new", "null",
43
+ "of", "package", "private", "protected", "public", "readonly", "return", "set",
44
+ "static", "struct", "super", "switch", "this", "throw", "true", "try", "type",
45
+ "typeof", "undefined", "var", "void", "while", "yield",
46
+ ]),
47
+ python: new Set([
48
+ "and", "as", "assert", "async", "await", "break", "class", "continue", "def",
49
+ "del", "elif", "else", "except", "False", "finally", "for", "from", "global",
50
+ "if", "import", "in", "is", "lambda", "None", "nonlocal", "not", "or", "pass",
51
+ "raise", "return", "True", "try", "while", "with", "yield",
52
+ ]),
53
+ shell: new Set([
54
+ "case", "cd", "do", "done", "echo", "elif", "else", "esac", "exit", "export",
55
+ "fi", "for", "function", "if", "in", "local", "return", "set", "then", "while",
56
+ ]),
57
+ json: new Set(["false", "null", "true"]),
58
+ none: new Set(),
59
+ };
60
+ const LINE_COMMENT = {
61
+ clike: ["//"],
62
+ python: ["#"],
63
+ shell: ["#"],
64
+ json: [],
65
+ none: [],
66
+ };
67
+ const WORD = /[A-Za-z_$][A-Za-z0-9_$]*/y;
68
+ // Hex first: the decimal branch would otherwise match the leading 0 of 0x1f
69
+ // and leave the rest of the literal looking like an identifier.
70
+ const NUMBER = /0[xXbBoO][0-9a-fA-F_]+|\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
71
+ export function family(lang) {
72
+ return FAMILIES[lang.trim().toLowerCase().split(/\s+/)[0] ?? ""] ?? "none";
73
+ }
74
+ /**
75
+ * Tokenize a whole block, because a block comment and a template string both
76
+ * outlive the line they open on. State is carried between lines rather than
77
+ * rediscovered, which is the only way the row after `/*` comes out grey.
78
+ */
79
+ export function highlight(lines, lang) {
80
+ const kind = family(lang);
81
+ if (kind === "none")
82
+ return lines.map((line) => [{ text: line, role: "plain" }]);
83
+ const keywords = KEYWORDS[kind];
84
+ const comments = LINE_COMMENT[kind];
85
+ let inBlock = false;
86
+ return lines.map((line) => {
87
+ const tokens = [];
88
+ let plain = "";
89
+ let i = 0;
90
+ const flush = () => {
91
+ if (plain !== "")
92
+ tokens.push({ text: plain, role: "plain" });
93
+ plain = "";
94
+ };
95
+ const take = (text, role) => {
96
+ flush();
97
+ tokens.push({ text, role });
98
+ i += text.length;
99
+ };
100
+ while (i < line.length) {
101
+ if (inBlock) {
102
+ const end = line.indexOf("*/", i);
103
+ const stop = end === -1 ? line.length : end + 2;
104
+ take(line.slice(i, stop), "comment");
105
+ inBlock = end === -1;
106
+ continue;
107
+ }
108
+ const rest = line.slice(i);
109
+ if (kind === "clike" && rest.startsWith("/*")) {
110
+ inBlock = true;
111
+ continue;
112
+ }
113
+ const comment = comments.find((mark) => rest.startsWith(mark));
114
+ if (comment !== undefined) {
115
+ take(rest, "comment");
116
+ continue;
117
+ }
118
+ const quote = line[i];
119
+ if (quote === '"' || quote === "'" || quote === "`") {
120
+ take(line.slice(i, closingQuote(line, i, quote)), "string");
121
+ continue;
122
+ }
123
+ NUMBER.lastIndex = i;
124
+ const number = NUMBER.exec(line);
125
+ if (number !== null && number.index === i) {
126
+ take(number[0], "number");
127
+ continue;
128
+ }
129
+ WORD.lastIndex = i;
130
+ const word = WORD.exec(line);
131
+ if (word !== null && word.index === i) {
132
+ if (keywords.has(word[0]))
133
+ take(word[0], "keyword");
134
+ else {
135
+ plain += word[0];
136
+ i += word[0].length;
137
+ }
138
+ continue;
139
+ }
140
+ plain += quote;
141
+ i += 1;
142
+ }
143
+ flush();
144
+ return tokens.length === 0 ? [{ text: "", role: "plain" }] : tokens;
145
+ });
146
+ }
147
+ /** Index just past the closing quote, or the end of the line if it never comes. */
148
+ function closingQuote(line, start, quote) {
149
+ for (let i = start + 1; i < line.length; i++) {
150
+ if (line[i] === "\\") {
151
+ i++;
152
+ continue;
153
+ }
154
+ if (line[i] === quote)
155
+ return i + 1;
156
+ }
157
+ return line.length;
158
+ }