@vincemakes/kiso-tui-cells 0.46.2 → 0.47.1

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/dist/diff.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * v2e — the diff renderer: edit/write changes as inline ± lines, zero
3
- * dependencies, no syntax highlighting (the spec's scope line). Shown at
4
- * the approval moment ONLY — the frozen summary stays one line (v2d's
5
- * anti-leak principle), /last has the full data.
3
+ * dependencies, no syntax highlighting (the spec's scope line). v2e drew
4
+ * it at the approval moment only; Graphite §6 (R2a) draws it on every
5
+ * edit's card too, from the call's own hunks (`hunksDiff`) — DECLARED
6
+ * REVERSAL of "the frozen summary stays one line".
6
7
  *
7
8
  * edit_file diffs IN PLACE (the search→replace windows are known — no
8
9
  * general engine needed); write_file does a row-level LCS over the old
@@ -17,7 +18,17 @@
17
18
  export type DiffLine = {
18
19
  kind: "-" | "+" | " " | "note";
19
20
  text: string;
21
+ /** Graphite §6 (R2a): the changed WORDS of a line that replaced one
22
+ * other line — `[start, end)` offsets into `text`, sorted, disjoint.
23
+ * Absent everywhere else. */
24
+ marks?: readonly (readonly [number, number])[];
20
25
  };
26
+ /** One change an edit_file call asks for: the text it looks for, and what
27
+ * replaces it. */
28
+ export interface Hunk {
29
+ readonly search: string;
30
+ readonly replace: string;
31
+ }
21
32
  export interface DiffResult {
22
33
  /** The FULL diff (with context, not truncated) — the display truncates. */
23
34
  lines: DiffLine[];
@@ -44,6 +55,8 @@ export interface DiffResult {
44
55
  * changes meaning. */
45
56
  notFound?: true;
46
57
  }
58
+ /** A line-level LCS diff — the classic two-row DP, ~small inputs. */
59
+ export declare function lcsDiff(oldLines: string[], newLines: string[]): DiffLine[];
47
60
  /** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
48
61
  export declare function truncateDiff(diff: DiffLine[]): DiffLine[];
49
62
  /** edit_file: the preview of a CHARACTER splice.
@@ -72,3 +85,27 @@ export declare function editFileDiff(oldContent: string, search: string, replace
72
85
  /** write_file: a new file is all +; an existing file diffs row-level
73
86
  * against its old content. */
74
87
  export declare function writeFileDiff(oldContent: string | null, newContent: string): DiffResult;
88
+ /** The hunks an edit_file call carries: its `edits`, or its one
89
+ * `search`/`replace` pair. Null when the input has neither (the tool
90
+ * refuses such a call, so there is nothing to draw). */
91
+ export declare function hunksOf(input: Record<string, unknown>): Hunk[] | null;
92
+ /** Graphite §6 (R2a) — what an edit CHANGED, drawn from the call's own
93
+ * hunks: per hunk a line LCS of `search` against `replace`, the lines
94
+ * both keep as context (two around each change), a `···` note between
95
+ * hunks, and word marks where one line replaced one line. The file is
96
+ * never read, so the diff is a pure function of the durable log — the
97
+ * same live, after a reprint, after resume. */
98
+ export declare function hunksDiff(hunks: readonly Hunk[]): DiffResult;
99
+ /** The approval preview of a batch: the hunks applied IN ORDER, each to
100
+ * the text the ones before it left — the tool's own rule (ACI-3) — and
101
+ * the result diffed against the file. A hunk the tool would refuse (not
102
+ * there, or there more than once) is named, and nothing is drawn: the
103
+ * tool writes all of the batch or none of it. */
104
+ export declare function editFileHunksDiff(oldContent: string, hunks: readonly Hunk[], path?: string): DiffResult;
105
+ /** A line's tokens: runs of word characters, runs of spaces, and every
106
+ * other character on its own. Joined, they are the line. */
107
+ export declare function wordTokens(line: string): string[];
108
+ /** The token LCS of two lines: for each side, the `[start, end)` offsets
109
+ * of the tokens the other side does not keep, adjacent ones merged.
110
+ * Null when too little survives for marks to mean anything. */
111
+ export declare function tokenMarks(a: string, b: string): [Array<[number, number]> | null, Array<[number, number]> | null];
package/dist/diff.js CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * v2e — the diff renderer: edit/write changes as inline ± lines, zero
3
- * dependencies, no syntax highlighting (the spec's scope line). Shown at
4
- * the approval moment ONLY — the frozen summary stays one line (v2d's
5
- * anti-leak principle), /last has the full data.
3
+ * dependencies, no syntax highlighting (the spec's scope line). v2e drew
4
+ * it at the approval moment only; Graphite §6 (R2a) draws it on every
5
+ * edit's card too, from the call's own hunks (`hunksDiff`) — DECLARED
6
+ * REVERSAL of "the frozen summary stays one line".
6
7
  *
7
8
  * edit_file diffs IN PLACE (the search→replace windows are known — no
8
9
  * general engine needed); write_file does a row-level LCS over the old
@@ -11,7 +12,7 @@
11
12
  * from the full diff.
12
13
  */
13
14
  /** A line-level LCS diff — the classic two-row DP, ~small inputs. */
14
- function lcsDiff(oldLines, newLines) {
15
+ export function lcsDiff(oldLines, newLines) {
15
16
  const n = oldLines.length;
16
17
  const m = newLines.length;
17
18
  const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
@@ -138,7 +139,7 @@ export function editFileDiff(oldContent, search, replace, path) {
138
139
  };
139
140
  }
140
141
  const result = oldContent.slice(0, at) + replace + oldContent.slice(at + search.length);
141
- const lines = withContext(lcsDiff(oldContent.split("\n"), result.split("\n")));
142
+ const lines = withMarks(withContext(lcsDiff(oldContent.split("\n"), result.split("\n"))));
142
143
  return { lines, ...stats(lines), outcome: "diff" };
143
144
  }
144
145
  /** write_file: a new file is all +; an existing file diffs row-level
@@ -151,3 +152,139 @@ export function writeFileDiff(oldContent, newContent) {
151
152
  const lines = withContext(lcsDiff(oldContent.split("\n"), newContent.split("\n")));
152
153
  return { lines, ...stats(lines), outcome: "diff" };
153
154
  }
155
+ /** The hunks an edit_file call carries: its `edits`, or its one
156
+ * `search`/`replace` pair. Null when the input has neither (the tool
157
+ * refuses such a call, so there is nothing to draw). */
158
+ export function hunksOf(input) {
159
+ const pair = (x) => {
160
+ if (typeof x !== "object" || x === null)
161
+ return null;
162
+ const { search, replace } = x;
163
+ return typeof search === "string" && typeof replace === "string" ? { search, replace } : null;
164
+ };
165
+ if (Array.isArray(input.edits)) {
166
+ const hunks = input.edits.map(pair);
167
+ return hunks.length > 0 && hunks.every((h) => h !== null) ? hunks : null;
168
+ }
169
+ const one = pair(input);
170
+ return one === null ? null : [one];
171
+ }
172
+ /** Graphite §6 (R2a) — what an edit CHANGED, drawn from the call's own
173
+ * hunks: per hunk a line LCS of `search` against `replace`, the lines
174
+ * both keep as context (two around each change), a `···` note between
175
+ * hunks, and word marks where one line replaced one line. The file is
176
+ * never read, so the diff is a pure function of the durable log — the
177
+ * same live, after a reprint, after resume. */
178
+ export function hunksDiff(hunks) {
179
+ const lines = [];
180
+ for (const [i, h] of hunks.entries()) {
181
+ const one = withMarks(withContext(lcsDiff(h.search.split("\n"), h.replace.split("\n"))));
182
+ if (one.length === 0)
183
+ continue;
184
+ if (i > 0 && lines.length > 0)
185
+ lines.push({ kind: "note", text: "\u00b7\u00b7\u00b7" });
186
+ lines.push(...one);
187
+ }
188
+ return { lines, ...stats(lines), outcome: "diff" };
189
+ }
190
+ /** The approval preview of a batch: the hunks applied IN ORDER, each to
191
+ * the text the ones before it left — the tool's own rule (ACI-3) — and
192
+ * the result diffed against the file. A hunk the tool would refuse (not
193
+ * there, or there more than once) is named, and nothing is drawn: the
194
+ * tool writes all of the batch or none of it. */
195
+ export function editFileHunksDiff(oldContent, hunks, path) {
196
+ if (hunks.length === 1)
197
+ return editFileDiff(oldContent, hunks[0].search, hunks[0].replace, path);
198
+ const where = path ?? "the file";
199
+ let edited = oldContent;
200
+ for (const [i, h] of hunks.entries()) {
201
+ const at = edited.indexOf(h.search);
202
+ const which = i === 0 ? "hunk 1" : `hunk ${i + 1}, after ${i === 1 ? "hunk 1" : `hunks 1\u2013${i}`} applied`;
203
+ if (at < 0)
204
+ return { lines: [{ kind: "note", text: `pattern not found in ${where} (${which})` }], added: 0, removed: 0, outcome: "not-found", notFound: true };
205
+ if (h.search.length > 0 && edited.indexOf(h.search, at + 1) > at) {
206
+ return { lines: [{ kind: "note", text: `pattern matches more than one place in ${where} (${which})` }], added: 0, removed: 0, outcome: "ambiguous" };
207
+ }
208
+ edited = edited.slice(0, at) + h.replace + edited.slice(at + h.search.length);
209
+ }
210
+ const lines = withMarks(withContext(lcsDiff(oldContent.split("\n"), edited.split("\n"))));
211
+ return { lines, ...stats(lines), outcome: "diff" };
212
+ }
213
+ /** A line's tokens: runs of word characters, runs of spaces, and every
214
+ * other character on its own. Joined, they are the line. */
215
+ export function wordTokens(line) {
216
+ return line.match(/[\p{L}\p{N}_]+|\s+|[\s\S]/gu) ?? [];
217
+ }
218
+ /** The share of a replaced line's tokens (spaces aside) that must survive
219
+ * for word marks to help: below it the whole line changed, and marking
220
+ * nearly every word says less than the line's own colour does. */
221
+ const MARK_MIN_KEPT = 0.34;
222
+ /** Word marks on a diff: every `-` line directly followed by one `+` line,
223
+ * with no other change beside the pair, gets the offsets of the tokens
224
+ * the other side does not keep (a token LCS). */
225
+ function withMarks(diff) {
226
+ const changed = (k) => k >= 0 && k < diff.length && (diff[k].kind === "-" || diff[k].kind === "+");
227
+ return diff.map((d, k) => {
228
+ const pairAt = d.kind === "-" ? k : d.kind === "+" ? k - 1 : -1;
229
+ if (pairAt < 0 || diff[pairAt]?.kind !== "-" || diff[pairAt + 1]?.kind !== "+" || changed(pairAt - 1) || changed(pairAt + 2))
230
+ return d;
231
+ const [oldMarks, newMarks] = tokenMarks(diff[pairAt].text, diff[pairAt + 1].text);
232
+ if (oldMarks === null || newMarks === null)
233
+ return d;
234
+ const marks = d.kind === "-" ? oldMarks : newMarks;
235
+ return marks.length === 0 ? d : { ...d, marks };
236
+ });
237
+ }
238
+ /** The token LCS of two lines: for each side, the `[start, end)` offsets
239
+ * of the tokens the other side does not keep, adjacent ones merged.
240
+ * Null when too little survives for marks to mean anything. */
241
+ export function tokenMarks(a, b) {
242
+ const ta = wordTokens(a);
243
+ const tb = wordTokens(b);
244
+ const n = ta.length;
245
+ const m = tb.length;
246
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
247
+ for (let i = n - 1; i >= 0; i -= 1) {
248
+ for (let j = m - 1; j >= 0; j -= 1)
249
+ dp[i][j] = ta[i] === tb[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
250
+ }
251
+ const keptA = new Array(n).fill(false);
252
+ const keptB = new Array(m).fill(false);
253
+ for (let i = 0, j = 0; i < n && j < m;) {
254
+ if (ta[i] === tb[j]) {
255
+ keptA[i] = true;
256
+ keptB[j] = true;
257
+ i += 1;
258
+ j += 1;
259
+ }
260
+ else if (dp[i + 1][j] >= dp[i][j + 1])
261
+ i += 1;
262
+ else
263
+ j += 1;
264
+ }
265
+ const solid = (t) => /\S/.test(t);
266
+ const words = ta.filter(solid).length + tb.filter(solid).length;
267
+ const kept = ta.filter((t, i) => keptA[i] && solid(t)).length + tb.filter((t, j) => keptB[j] && solid(t)).length;
268
+ if (words === 0 || kept / words < MARK_MIN_KEPT)
269
+ return [null, null];
270
+ return [spans(ta, keptA), spans(tb, keptB)];
271
+ }
272
+ /** The offsets of the tokens not kept, adjacent spans merged; a run of
273
+ * spaces between two changed tokens joins them. */
274
+ function spans(tokens, kept) {
275
+ const line = tokens.join("");
276
+ const out = [];
277
+ let at = 0;
278
+ for (const [i, t] of tokens.entries()) {
279
+ const end = at + t.length;
280
+ if (!kept[i] && /\S/.test(t)) {
281
+ const last = out[out.length - 1];
282
+ if (last !== undefined && /^\s*$/.test(line.slice(last[1], at)))
283
+ last[1] = end;
284
+ else
285
+ out.push([at, end]);
286
+ }
287
+ at = end;
288
+ }
289
+ return out;
290
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Graphite (design.md §2, §3.4) — the colour tokens, the output tier, and
3
+ * the surfaces derived from the ground the terminal actually reported.
4
+ *
5
+ * PURE: no process state is read except where a caller passes it in, so
6
+ * every decision here is testable without a terminal. `render.ts` turns
7
+ * these colours into SGR bytes; this module only decides the colours.
8
+ */
9
+ import { type Rgb } from "./ground.js";
10
+ export type Kind = "light" | "dark";
11
+ /** How colour reaches the terminal: 24-bit (`38;2;r;g;b`) where the
12
+ * terminal says it renders it, the nearest xterm-256 index otherwise. */
13
+ export type Tier = "24bit" | "256";
14
+ /** The Graphite table (design.md §2), on the two reference grounds. */
15
+ export declare const GRAPHITE: {
16
+ readonly light: {
17
+ readonly ground: "#ffffff";
18
+ readonly ink: "#111111";
19
+ readonly ink2: "#444444";
20
+ readonly dim: "#646464";
21
+ readonly rail: "#8c8c8c";
22
+ readonly line: "#e6e6e6";
23
+ readonly washRun: "#edf2fb";
24
+ readonly washDone: "#f1f1f1";
25
+ readonly washFail: "#fbecea";
26
+ readonly washAsk: "#f7f1e3";
27
+ readonly human: "#f7efdc";
28
+ readonly humanInk: "#171923";
29
+ readonly gold: "#8a5a00";
30
+ readonly goldMark: "#c9921f";
31
+ readonly blue: "#2456b5";
32
+ readonly code: "#e4ebf8";
33
+ readonly ok: "#2f7a3a";
34
+ readonly fail: "#b3261e";
35
+ readonly add: "#dff0e2";
36
+ readonly del: "#fadfdc";
37
+ readonly track: "#e2e2e2";
38
+ };
39
+ readonly dark: {
40
+ readonly ground: "#0b0b0b";
41
+ readonly ink: "#ededed";
42
+ readonly ink2: "#c2c2c2";
43
+ readonly dim: "#a4a4a4";
44
+ readonly rail: "#6b6b6b";
45
+ readonly line: "#656565";
46
+ readonly washRun: "#2b3854";
47
+ readonly washDone: "#333333";
48
+ readonly washFail: "#4d2b28";
49
+ readonly washAsk: "#483818";
50
+ readonly human: "#e8dfc6";
51
+ readonly humanInk: "#141620";
52
+ readonly gold: "#e3b04b";
53
+ readonly goldMark: "#e3b04b";
54
+ readonly blue: "#82a8f5";
55
+ readonly code: "#283658";
56
+ readonly ok: "#8fd19e";
57
+ readonly fail: "#f2877a";
58
+ readonly add: "#2a5034";
59
+ readonly del: "#5e302d";
60
+ readonly track: "#474747";
61
+ };
62
+ };
63
+ export type Token = Exclude<keyof (typeof GRAPHITE)["light"], "ground">;
64
+ export type Colours = {
65
+ readonly [K in Token | "ground"]: Rgb;
66
+ };
67
+ /** The tokens that are GROUNDS near the terminal's own (§3.4): each is
68
+ * derived from the reported ground. Every other token is fixed per kind. */
69
+ export declare const SURFACES: readonly ["line", "washRun", "washDone", "washFail", "washAsk", "human", "code", "add", "del", "track"];
70
+ /** The text tokens that read on the ground and on the card surfaces (§2.1). */
71
+ export declare const CARD_TEXT: readonly ["ink", "ink2", "dim", "gold", "blue", "ok", "fail"];
72
+ export declare const CARDS: readonly ["washRun", "washDone", "washFail", "washAsk"];
73
+ type Surface = Token | "ground";
74
+ /** Every text-on-surface pair a human reads (§2.1's floor applies to each). */
75
+ export declare const TEXT_PAIRS: readonly (readonly [Token, Surface])[];
76
+ export declare const FLOOR = 4.5;
77
+ /** The floor for a GRAPHIC — a mark, not text (WCAG 1.4.11). The breath
78
+ * never drops below it (§2.2). */
79
+ export declare const GRAPHIC_FLOOR = 3;
80
+ export declare function hexRgb(hex: string): Rgb;
81
+ export declare function rgbHex({ r, g, b }: Rgb): string;
82
+ /** The WCAG contrast ratio, the measure of §2's floor. */
83
+ export declare function contrast(a: Rgb, b: Rgb): number;
84
+ /** `t` of the way from `a` to `b`, per channel. */
85
+ export declare function mix(a: Rgb, b: Rgb, t: number): Rgb;
86
+ /**
87
+ * §3.4 — one surface for any ground. A DARK ground: per channel an affine
88
+ * map `s = a + k·g`, fitted so that the white reference ground gives the
89
+ * table's white value and the black one its black value — a terminal
90
+ * whose black is `#1e1e1e` gets a card that sits the same distance off
91
+ * ITS ground, and the distance narrows as a dark ground lightens, which
92
+ * keeps the text above the floor on the lighter dark themes. A LIGHT
93
+ * ground: the light table, moved by how far the ground sits from white.
94
+ * The two reference grounds reproduce the table exactly.
95
+ *
96
+ * 0.47.1 (the dark table's re-adaptation): DECLARED REVERSAL for the
97
+ * light grounds only. The one map through both tables tied them to the
98
+ * dark table — lifting the dark cards pulled a card on `#eeeeee` to within
99
+ * 1.09:1 of its ground. A light ground now answers to the light table
100
+ * alone; on the light grounds measured the cards move by a channel step
101
+ * or two at most.
102
+ */
103
+ export declare function deriveSurface(token: Token, ground: Rgb): Rgb;
104
+ /** A colour as the terminal will show it: itself in the 24-bit tier, the
105
+ * nearest xterm-256 entry in the 256 tier. The floor is checked on what
106
+ * is SHOWN — rounding to the nearest index can cost a pair its contrast. */
107
+ export declare function shown(c: Rgb, tier: Tier): Rgb;
108
+ /** The weakest text-on-surface pair of a colour set, as shown in `tier`. */
109
+ export declare function weakestPair(c: Colours, tier?: Tier): {
110
+ pair: readonly [Token, Surface];
111
+ ratio: number;
112
+ };
113
+ export declare function graphiteColours(kind: Kind, ground: Rgb | null, tier?: Tier): Colours;
114
+ /**
115
+ * The command breath (§5.2): seven steps from `gold` toward the running
116
+ * card's ground, peak → floor → peak. The floor is the deepest step that
117
+ * still meets the graphic floor on that ground (§2.2): the mark dims and
118
+ * never disappears.
119
+ */
120
+ export declare function breathRamp(c: Colours): readonly Rgb[];
121
+ /** The xterm-256 palette's colour for an index 16–255 (the cube and the
122
+ * grey ramp; the first sixteen are the terminal's own and never chosen). */
123
+ export declare function xterm256(index: number): Rgb;
124
+ /** The nearest xterm-256 index (16–255) to a colour, by squared distance
125
+ * in RGB — the 256 tier's whole translation. */
126
+ export declare function nearest256(c: Rgb): number;
127
+ /** §2 — 24-bit where the terminal says it renders it. `COLORTERM` is the
128
+ * standard statement; anything else gets the 256 tier, which every
129
+ * terminal kiso docks in renders. */
130
+ export declare function colourTier(colorterm: string | undefined): Tier;
131
+ /** 0.47.1 (owner, 2026-10-07) — 24-bit where the terminal is KNOWN to
132
+ * render it, though it does not say so in COLORTERM. Windows Terminal is
133
+ * the case that found it: drawn in the 256 tier, the person's cream block
134
+ * became pink (index 224) and its gold edge olive (186). Each terminal is
135
+ * recognised by what it sets; the list is the reference implementation's.
136
+ * Inside tmux or screen only COLORTERM counts — the outer terminal's
137
+ * variables are inherited, its rendering is not. Apple Terminal says it
138
+ * in COLORTERM where it renders 24-bit (§2), and keeps the 256 tier
139
+ * otherwise. PURE: the environment and the platform are passed in. */
140
+ export declare function terminalTier(env: Readonly<Record<string, string | undefined>>, platform: string): Tier;
141
+ export declare function fg(c: Rgb, tier: Tier): string;
142
+ export declare function bg(c: Rgb, tier: Tier): string;
143
+ export {};