atris 3.74.7 → 3.74.9

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/lib/text-table.js CHANGED
@@ -6,6 +6,16 @@
6
6
  // than the terminal, columns marked `clip` or `wrap` shrink (rightmost
7
7
  // first): a clip column ends in "…", a wrap column carries on underneath.
8
8
  // Every other column keeps its full width.
9
+ //
10
+ // renderBorderedTable draws the same rows as a divided box, the look of the
11
+ // orb's team table. A box wider than the terminal narrows its `wrap`
12
+ // columns, the widest first, and their words carry on underneath; when it
13
+ // still does not fit it returns null and the caller prints its plain view.
14
+ //
15
+ // Every width here is terminal columns, not string length (displayWidth):
16
+ // a wide or fullwidth character takes 2, a joiner, variation selector, or
17
+ // combining mark takes 0, and a joined emoji is one 2-column cluster. Cuts
18
+ // and wraps fall between clusters, never inside one.
9
19
 
10
20
  const GAP = ' ';
11
21
  const ELLIPSIS = '…';
@@ -16,25 +26,110 @@ function terminalWidth(width) {
16
26
  return Number.isFinite(value) && value >= 20 ? value : FALLBACK_WIDTH;
17
27
  }
18
28
 
29
+ // East Asian wide and fullwidth code points, as [first, last] ranges.
30
+ const WIDE = [
31
+ [0x1100, 0x115f], [0x231a, 0x231b], [0x2329, 0x232a], [0x2e80, 0x303e], [0x3041, 0x33ff],
32
+ [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xa960, 0xa97f], [0xac00, 0xd7a3],
33
+ [0xf900, 0xfaff], [0xfe10, 0xfe19], [0xfe30, 0xfe6f], [0xff00, 0xff60], [0xffe0, 0xffe6],
34
+ [0x16fe0, 0x16fe4], [0x17000, 0x18cff], [0x1aff0, 0x1b2ff], [0x1f200, 0x1f2ff],
35
+ [0x20000, 0x2fffd], [0x30000, 0x3fffd],
36
+ ];
37
+ // takes no column: combining marks, joiners and other format characters,
38
+ // variation selectors, and the Hangul vowel and final jamo that join a syllable
39
+ const ZERO = /^[\p{Mn}\p{Me}\p{Cf}\u1160-\u11ff\ud7b0-\ud7ff]$/u;
40
+ // drawn as a 2-column emoji: emoji presentation, or an emoji asked for one
41
+ // with the U+FE0F selector or a keycap
42
+ const EMOJI = /\p{Emoji_Presentation}|\p{Emoji}\ufe0f|\u20e3/u;
43
+ const SEGMENTER = typeof Intl === 'object' && typeof Intl.Segmenter === 'function'
44
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
45
+ : null;
46
+
47
+ function isWide(code) {
48
+ for (const [first, last] of WIDE) {
49
+ if (code < first) return false;
50
+ if (code <= last) return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ // The text as grapheme clusters: what a reader sees as one character.
56
+ // Without Intl.Segmenter a zero-width code point joins the cluster before it.
57
+ function graphemes(text) {
58
+ const value = String(text);
59
+ if (SEGMENTER) return Array.from(SEGMENTER.segment(value), (part) => part.segment);
60
+ const out = [];
61
+ let joined = false;
62
+ for (const ch of value) {
63
+ if (out.length && (joined || ZERO.test(ch))) out[out.length - 1] += ch;
64
+ else out.push(ch);
65
+ joined = ch === '\u200d';
66
+ }
67
+ return out;
68
+ }
69
+
70
+ // Terminal columns for one cluster: 0, 1, or 2.
71
+ function clusterWidth(cluster) {
72
+ const code = cluster.codePointAt(0);
73
+ if (cluster.length === 1 && code >= 0x20 && code < 0x7f) return 1;
74
+ if (code < 0x20 || (code >= 0x7f && code < 0xa0)) return 0;
75
+ if (EMOJI.test(cluster)) return 2;
76
+ for (const ch of cluster) {
77
+ if (!ZERO.test(ch)) return isWide(ch.codePointAt(0)) ? 2 : 1;
78
+ }
79
+ return 0;
80
+ }
81
+
82
+ // Terminal columns the text takes.
83
+ function displayWidth(text) {
84
+ const value = String(text);
85
+ // printable ascii is one column a character
86
+ if (/^[\x20-\x7e]*$/.test(value)) return value.length;
87
+ let total = 0;
88
+ for (const cluster of graphemes(value)) total += clusterWidth(cluster);
89
+ return total;
90
+ }
91
+
92
+ // The longest start of the text that fits in that many columns, whole
93
+ // clusters only.
94
+ function sliceColumns(text, columns) {
95
+ let out = '';
96
+ let used = 0;
97
+ for (const cluster of graphemes(text)) {
98
+ const w = clusterWidth(cluster);
99
+ if (used + w > columns) break;
100
+ out += cluster;
101
+ used += w;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ // Pads with spaces to that many columns.
107
+ function padColumns(text, columns) {
108
+ const value = String(text);
109
+ return value + ' '.repeat(Math.max(0, columns - displayWidth(value)));
110
+ }
111
+
19
112
  function clipText(text, width) {
20
113
  const value = String(text);
21
- if (value.length <= width) return value;
114
+ if (displayWidth(value) <= width) return value;
22
115
  if (width <= 1) return ELLIPSIS.slice(0, Math.max(0, width));
23
- return `${value.slice(0, width - 1).trimEnd()}${ELLIPSIS}`;
116
+ return `${sliceColumns(value, width - 1).trimEnd()}${ELLIPSIS}`;
24
117
  }
25
118
 
26
- // Word-wrap one line to a width; a word longer than the width is split.
119
+ // Word-wrap one line to a width in columns; a word wider than the width is
120
+ // split between clusters (a lone cluster wider than the width stands alone).
27
121
  function wrapText(text, width) {
28
122
  const out = [];
29
123
  let line = '';
30
124
  for (let word of String(text).split(' ')) {
31
- while (word.length > width) {
125
+ while (displayWidth(word) > width) {
32
126
  if (line) { out.push(line); line = ''; }
33
- out.push(word.slice(0, width));
34
- word = word.slice(width);
127
+ const head = sliceColumns(word, width) || graphemes(word)[0];
128
+ out.push(head);
129
+ word = word.slice(head.length);
35
130
  }
36
131
  const next = line ? `${line} ${word}` : word;
37
- if (next.length > width && line) { out.push(line); line = word; } else line = next;
132
+ if (displayWidth(next) > width && line) { out.push(line); line = word; } else line = next;
38
133
  }
39
134
  out.push(line);
40
135
  return out;
@@ -54,19 +149,19 @@ function renderTable(columns, rows, { width } = {}) {
54
149
  const max = terminalWidth(width);
55
150
  const cells = rows.map((row) => columns.map((_, index) => cellLines(row[index])));
56
151
  const widths = columns.map((column, index) => Math.max(
57
- String(column.header).length,
58
- ...cells.map((row) => Math.max(...row[index].map((line) => line.length))),
152
+ displayWidth(column.header),
153
+ ...cells.map((row) => Math.max(...row[index].map(displayWidth))),
59
154
  ));
60
155
  let over = widths.reduce((sum, value) => sum + value, 0) + GAP.length * (columns.length - 1) - max;
61
156
  for (let index = columns.length - 1; index >= 0 && over > 0; index -= 1) {
62
157
  if (!columns[index].clip && !columns[index].wrap) continue;
63
- const floor = Math.max(String(columns[index].header).length, columns[index].min || 8);
158
+ const floor = Math.max(displayWidth(columns[index].header), columns[index].min || 8);
64
159
  const cut = Math.min(over, Math.max(0, widths[index] - floor));
65
160
  widths[index] -= cut;
66
161
  over -= cut;
67
162
  }
68
163
  const line = (parts) => parts
69
- .map((text, index) => clipText(text, widths[index]).padEnd(index === parts.length - 1 ? 0 : widths[index]))
164
+ .map((text, index) => padColumns(clipText(text, widths[index]), index === parts.length - 1 ? 0 : widths[index]))
70
165
  .join(GAP)
71
166
  .trimEnd();
72
167
  const out = [line(columns.map((column) => column.header))];
@@ -78,4 +173,35 @@ function renderTable(columns, rows, { width } = {}) {
78
173
  return out.join('\n');
79
174
  }
80
175
 
81
- module.exports = { FALLBACK_WIDTH, clipText, renderTable, terminalWidth };
176
+ // columns: [{ header, wrap?, min? }]; rows: arrays of cells. A divided box,
177
+ // or null when it cannot fit the terminal.
178
+ function renderBorderedTable(columns, rows, { width } = {}) {
179
+ const cells = rows.map((row) => columns.map((_, index) => cellLines(row[index])));
180
+ const widths = columns.map((column, index) => Math.max(
181
+ displayWidth(column.header),
182
+ ...cells.map((row) => Math.max(...row[index].map(displayWidth))),
183
+ ));
184
+ const floor = (index) => Math.max(displayWidth(columns[index].header), columns[index].min || 8);
185
+ let over = widths.reduce((sum, value) => sum + value + 3, 1) - terminalWidth(width);
186
+ while (over > 0) {
187
+ let widest = -1;
188
+ columns.forEach((column, index) => {
189
+ if (column.wrap && widths[index] > floor(index) && (widest < 0 || widths[index] > widths[widest])) widest = index;
190
+ });
191
+ if (widest < 0) return null;
192
+ widths[widest] -= 1;
193
+ over -= 1;
194
+ }
195
+ const rule = (left, mid, right) => `${left}${widths.map((w) => '─'.repeat(w + 2)).join(mid)}${right}`;
196
+ const line = (parts) => `│ ${parts.map((text, index) => padColumns(text, widths[index])).join(' │ ')} │`;
197
+ const out = [rule('┌', '┬', '┐'), line(columns.map((column) => column.header)), rule('├', '┼', '┤')];
198
+ for (const raw of cells) {
199
+ const row = raw.map((lines, index) => (columns[index].wrap ? lines.flatMap((text) => wrapText(text, widths[index])) : lines));
200
+ const height = Math.max(...row.map((lines) => lines.length));
201
+ for (let at = 0; at < height; at += 1) out.push(line(row.map((lines) => lines[at] || '')));
202
+ }
203
+ out.push(rule('└', '┴', '┘'));
204
+ return out.join('\n');
205
+ }
206
+
207
+ module.exports = { FALLBACK_WIDTH, clipText, displayWidth, renderBorderedTable, renderTable, sliceColumns, terminalWidth, wrapText };
package/lib/zen-apply.js CHANGED
@@ -547,7 +547,7 @@ function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDa
547
547
  if (copies.busy.length) left.push(...sideCopyLines(copies.busy, staleDays, nextSaveIn(copies.busy, staleDays, now.getTime())));
548
548
  if (copies.scheduled) left.push(`${plural(copies.scheduled, 'side copy is', 'side copies are')} used by a scheduled job, so ${copies.scheduled === 1 ? 'it stays' : 'they stay'}`);
549
549
  if (copies.locked) left.push(`${plural(copies.locked, 'side copy is', 'side copies are')} locked by ${copies.locked === 1 ? 'its owner, so it stays' : 'their owners, so they stay'}`);
550
- if (copies.inUse) left.push(`${plural(copies.inUse, 'side copy was', 'side copies were')} used in ${window}`);
550
+ if (copies.inUse) left.push(`${plural(copies.inUse, 'side copy was', 'side copies were')} used in ${window}, so ${copies.inUse === 1 ? 'it stays' : 'they stay'}`);
551
551
 
552
552
  const stray = saveStrayEdits(root, teamState, tag, cutoff);
553
553
  if (stray.saved) {
package/lib/zen.js CHANGED
@@ -507,8 +507,21 @@ function shortPlace(p) {
507
507
  function sideCopyLines(paths, days, waitMs = null) {
508
508
  const n = paths.length;
509
509
  const lines = [`${n} side ${n === 1 ? 'copy has' : 'copies have'} unsaved work, left for ${n === 1 ? 'its owner' : 'their owners'}`];
510
- for (const p of paths.slice(0, 3)) lines.push(` ${shortPlace(p)}`);
511
- if (n > 3) lines.push(` and ${n - 3} more`);
510
+ // Copies that share a folder (one workflow's runs, say) read as one line.
511
+ const folders = new Map();
512
+ for (const p of paths) {
513
+ const dir = path.dirname(p);
514
+ if (!folders.has(dir)) folders.set(dir, []);
515
+ folders.get(dir).push(p);
516
+ }
517
+ const rows = [];
518
+ for (const [dir, group] of folders) {
519
+ if (group.length >= 3) rows.push({ text: `${group.length} in ${shortPlace(dir)}/`, count: group.length });
520
+ else for (const p of group) rows.push({ text: shortPlace(p), count: 1 });
521
+ }
522
+ for (const row of rows.slice(0, 3)) lines.push(` ${row.text}`);
523
+ const rest = rows.slice(3).reduce((sum, row) => sum + row.count, 0);
524
+ if (rest) lines.push(` and ${rest} more`);
512
525
  lines.push(`zen saves ${n === 1 ? 'it' : 'each one'} to zen-backup/ once nobody has touched it for ${days} ${days === 1 ? 'day' : 'days'}`);
513
526
  if (waitMs > 0) lines.push(`${n === 1 ? 'that is' : 'the first one is'} due in ${waitText(waitMs)}`);
514
527
  return lines;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.74.7",
3
+ "version": "3.74.9",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",
@@ -428,10 +428,14 @@ run_haiku() {
428
428
  # The Antigravity editor ships its own `agy` (a VS Code-style launcher script).
429
429
  # It has no -p mode: it hands -p and --model to Electron and prints nothing, so
430
430
  # it is not a writer. Only the Antigravity CLI is.
431
+ # Only grep's answer counts, never head's exit. In `head | grep -q` under
432
+ # pipefail, grep quits on the match and a head that touches the pipe once
433
+ # more (uutils head on Ubuntu 26.04 does, to find the end of the file) dies
434
+ # of SIGPIPE, so a busy machine read the launcher as the CLI.
431
435
  agy_is_cli() {
432
436
  local bin
433
437
  bin=$(command -v agy 2>/dev/null) || return 1
434
- ! head -c 4096 "$bin" 2>/dev/null | grep -q 'VSCODE_IPC_HOOK_CLI'
438
+ ! grep -q 'VSCODE_IPC_HOOK_CLI' < <(head -c 4096 "$bin" 2>/dev/null)
435
439
  }
436
440
 
437
441
  # An agy that is not signed in opens a Google login and waits up to 60s, which
@@ -441,7 +445,7 @@ agy_is_cli() {
441
445
  agy_signed_in() {
442
446
  local out
443
447
  out=$(run_with_timeout 10 agy models </dev/null 2>&1) || true
444
- ! printf '%s' "$out" | grep -qi 'sign in'
448
+ ! grep -qi 'sign in' <<< "$out"
445
449
  }
446
450
 
447
451
  run_gemini() {
@@ -524,7 +528,9 @@ run_auto() {
524
528
  echo "$tried did not answer; writing the notes with $label instead." >&2
525
529
  fi
526
530
  rm -f "$WORK/yt_${ID}.kill"
527
- if out=$(run_writer "$cmd" "$model" "$effort" 2>>"$WORK/yt_${ID}.engine.err") && printf '%s' "$out" | grep -q '[[:alnum:]]'; then
531
+ # A here-string, not printf | grep -q: under pipefail printf can die of
532
+ # SIGPIPE after grep's match and turn a real answer into "did not answer".
533
+ if out=$(run_writer "$cmd" "$model" "$effort" 2>>"$WORK/yt_${ID}.engine.err") && grep -q '[[:alnum:]]' <<< "$out"; then
528
534
  printf '%s\n' "$out"
529
535
  echo "notes by $label" >&2
530
536
  return 0