jaitus 0.3.14 → 0.3.16

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/src/tui/ansi.js CHANGED
@@ -188,6 +188,12 @@ export function truncateRow(row, width, ellipsis = '…') {
188
188
  * bullet or a wrapped tool row stay visually attached to its marker.
189
189
  */
190
190
  export function wrapRow(row, width, indent = '') {
191
+ // A newline inside a span is a line break, never a character to print: Ink would draw
192
+ // it as an extra screen line the layout never counted, and every row below it — and
193
+ // the selection — would be off by one. Pasted multi-line text arrives exactly so.
194
+ if (row.some((span) => typeof span.t === 'string' && span.t.includes('\n'))) {
195
+ return splitAtNewlines(row).flatMap((line) => wrapRow(line, width, indent));
196
+ }
191
197
  if (width < 4) return [row];
192
198
  if (rowWidth(row) <= width) return [row];
193
199
 
@@ -200,10 +206,15 @@ export function wrapRow(row, width, indent = '') {
200
206
  // Every line after the first opens with the hanging indent, so a wrapped bullet or a
201
207
  // wrapped tool row stays visually attached to its marker instead of resetting to
202
208
  // column zero and reading as a new item.
209
+ // A continuation line is the same line of text, so copying rejoins it (SOFT): the
210
+ // marker carries what the break swallowed — a space between words, nothing in a word
211
+ // cut mid-way. The hanging indent is layout, so it is never copied.
212
+ let breakJoin = ' ';
203
213
  const flush = () => {
204
214
  lines.push(current.length ? current : [{ t: '' }]);
205
- current = indent ? [...indentSpan] : [];
215
+ current = [{ t: '', soft: breakJoin }, ...(indent ? [{ ...indentSpan[0], copy: '' }] : [])];
206
216
  used = indent ? indentWidth : 0;
217
+ breakJoin = ' ';
207
218
  };
208
219
 
209
220
  for (const span of row) {
@@ -228,6 +239,7 @@ export function wrapRow(row, width, indent = '') {
228
239
  // `cut` marks a word split mid-way, so a URL cut here can be rejoined when it is
229
240
  // clicked (the renderer ignores the field).
230
241
  current.push(piece ? { ...span, t: piece, cut: true } : { ...span, t: piece });
242
+ breakJoin = '';
231
243
  flush();
232
244
  piece = '';
233
245
  pieceWidth = 0;
@@ -255,6 +267,58 @@ export function wrapRows(rows, width, indent = '') {
255
267
  /** The plain text of a row of spans, for selection, search and the clipboard. */
256
268
  export const rowText = (row) => (row || []).map((span) => span.t).join('');
257
269
 
270
+ /** One row with newlines in its spans → one row per line, styles kept. */
271
+ export function splitAtNewlines(row) {
272
+ const lines = [[]];
273
+ for (const span of row) {
274
+ const parts = String(span.t ?? '').split('\n');
275
+ parts.forEach((part, i) => {
276
+ if (i > 0) lines.push([]);
277
+ if (part) lines[lines.length - 1].push({ ...span, t: part });
278
+ });
279
+ }
280
+ return lines.map((line) => (line.length ? line : [{ t: '' }]));
281
+ }
282
+
283
+ // ── Copying what is on screen ──────────────────────────────────────────────────
284
+ //
285
+ // A row on screen is source text plus decoration: the bar beside a message, a code
286
+ // block's gutter and frame, a quote mark, a table's column rules, a bullet glyph, the
287
+ // indent under a wrapped line. A span says what it contributes to a COPY with `copy`
288
+ // (absent = its own text; '' = nothing; '- ' for a bullet), and a wrapped line says how
289
+ // it rejoins the line above with a `soft` marker. Copying a command out of an answer
290
+ // used to bring the gutter's ▐ (| in plain terminals) and the bullet with it.
291
+
292
+ /** True for a row that is decoration only (a code block's frame, a rule). */
293
+ export const isDecorationRow = (row) => (row || []).length > 0
294
+ && row.every((span) => span.copy === '' || !span.t)
295
+ && row.some((span) => span.t && span.copy === '');
296
+
297
+ /** How a row joins the row above when both are copied: null = a new line. */
298
+ export const softJoin = (row) => {
299
+ const marker = (row || []).find((span) => span.soft !== undefined);
300
+ return marker ? marker.soft : null;
301
+ };
302
+
303
+ /** The copy text of the cells [from, to) of a row (string offsets, as selection uses). */
304
+ export function copyText(row, from = 0, to = Infinity) {
305
+ let at = 0;
306
+ let out = '';
307
+ for (const span of row || []) {
308
+ const t = String(span.t ?? '');
309
+ const start = at;
310
+ const end = at + t.length;
311
+ at = end;
312
+ if (end <= from || start >= to) {
313
+ // A zero-width marker inside the range still counts (it is how bullets survive).
314
+ if (!(t.length === 0 && start >= from && start < to)) continue;
315
+ }
316
+ if (span.copy !== undefined) { out += span.copy; continue; }
317
+ out += t.slice(Math.max(0, from - start), Math.min(t.length, to - start));
318
+ }
319
+ return out;
320
+ }
321
+
258
322
  /**
259
323
  * Invert the cells between two columns of a row, splitting spans where the range falls
260
324
  * inside one. Used to show a drag selection: the terminal's own highlight is gone once
@@ -137,12 +137,18 @@ const BUILDERS = {
137
137
  // ── What the user said ───────────────────────────────────────────────────────
138
138
  user(data, width) {
139
139
  const p = palette();
140
- const bar = { t: pick(['▌', '|']), color: p.brand };
140
+ // One mark beside the message, on its first row — not a bar on every row: a pasted
141
+ // command or log used to arrive striped with ▌ (| in plain terminals) down its whole
142
+ // length. Later rows sit under the text, indented. Neither is ever copied.
143
+ const bar = { t: pick(['▌', '|']), color: p.brand, copy: '' };
144
+ const gap = { t: ' ', copy: '' };
145
+ const indent = { t: ' ', copy: '' };
141
146
  const inner = Math.max(12, width - 2);
142
- const body = wrapRow([{ t: String(data.text || ''), color: p.body, bold: true }], inner);
143
- const rows = body.map((row) => [bar, { t: ' ' }, ...row]);
147
+ const text = String(data.text || '').replace(/\r\n?/g, '\n').replace(/\t/g, ' ');
148
+ const body = wrapRow([{ t: text, color: p.body, bold: true }], inner);
149
+ const rows = body.map((row, index) => (index === 0 ? [bar, gap, ...row] : [indent, ...row]));
144
150
  if (data.attachments?.length) {
145
- rows.push([bar, { t: ' ' }, { t: data.attachments.map((a) => `⧉ ${a}`).join(' '), color: p.purple }]);
151
+ rows.push([indent, { t: data.attachments.map((a) => `⧉ ${a}`).join(' '), color: p.purple }]);
146
152
  }
147
153
  return rows;
148
154
  },
@@ -156,8 +162,8 @@ const BUILDERS = {
156
162
  // A lead mark on the first row only: it identifies the speaker without spending a
157
163
  // whole line on a name label, and the indent keeps the column under it aligned.
158
164
  return body.map((row, index) => (index === 0
159
- ? [{ t: pick(['● ', '* ']), color: p.teal }, ...row]
160
- : [{ t: ' ' }, ...row]));
165
+ ? [{ t: pick(['● ', '* ']), color: p.teal, copy: '' }, ...row]
166
+ : [{ t: ' ', copy: '' }, ...row]));
161
167
  },
162
168
 
163
169
  // ── A tool call ──────────────────────────────────────────────────────────────
@@ -197,11 +203,11 @@ const BUILDERS = {
197
203
  const tinted = highlightLine(line.replace(/\t/g, ' '), language, state);
198
204
  state = tinted.state;
199
205
  const spans = tinted.spans.length ? tinted.spans : [{ t: '' }];
200
- rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft }, ...truncateRow(spans, Math.max(8, width - 6))]);
206
+ rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft, copy: '' }, ...truncateRow(spans, Math.max(8, width - 6))]);
201
207
  }
202
208
  if (preview.more > 0) {
203
209
  const hint = openable && !data.expanded ? ' — click the row to show all' : '';
204
- rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft }, { t: `+${preview.more} more line${preview.more === 1 ? '' : 's'}${hint}`, color: p.faint, italic: true }]);
210
+ rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft, copy: '' }, { t: `+${preview.more} more line${preview.more === 1 ? '' : 's'}${hint}`, color: p.faint, italic: true }]);
205
211
  }
206
212
  }
207
213
  return rows;
@@ -262,7 +268,7 @@ const BUILDERS = {
262
268
  error(data, width) {
263
269
  const p = palette();
264
270
  const rows = [];
265
- const bar = { t: pick(['▌', '|']), color: p.red };
271
+ const bar = { t: pick(['▌', '|']), color: p.red, copy: '' };
266
272
  const inner = Math.max(12, width - 2);
267
273
 
268
274
  rows.push([bar, { t: ' ' }, { t: `${pick(['✗', 'x'])} ${data.title || 'Error'}`, color: p.red, bold: true }]);
@@ -122,14 +122,14 @@ export function renderMarkdown(text, width, { compact = false } = {}) {
122
122
  for (const row of wrapRow(content, w)) rows.push(row);
123
123
  if (level <= 2) {
124
124
  const ruleWidth = Math.min(w, Math.max(8, contentWidth(content)));
125
- rows.push([{ t: (level === 1 ? g('━', '=') : g('─', '-')).repeat(ruleWidth), color: level === 1 ? p.brandDeep : p.line }]);
125
+ rows.push([{ t: (level === 1 ? g('━', '=') : g('─', '-')).repeat(ruleWidth), color: level === 1 ? p.brandDeep : p.line, copy: '' }]);
126
126
  }
127
127
  i += 1;
128
128
  continue;
129
129
  }
130
130
 
131
131
  // ── horizontal rule ────────────────────────────────────────────────────────
132
- if (RULE.test(line)) { rows.push([{ t: g('─', '-').repeat(w), color: p.line }]); i += 1; continue; }
132
+ if (RULE.test(line)) { rows.push([{ t: g('─', '-').repeat(w), color: p.line, copy: '' }]); i += 1; continue; }
133
133
 
134
134
  // ── blockquote ─────────────────────────────────────────────────────────────
135
135
  const quote = QUOTE.exec(line);
@@ -138,7 +138,7 @@ export function renderMarkdown(text, width, { compact = false } = {}) {
138
138
  while (i < lines.length && QUOTE.test(lines[i])) { body.push(QUOTE.exec(lines[i])[1]); i += 1; }
139
139
  if (!compact) blank();
140
140
  for (const inner of renderMarkdown(body.join('\n'), w - 2, { compact: true })) {
141
- rows.push([{ t: g('▏ ', '| '), color: p.teal }, ...inner]);
141
+ rows.push([{ t: g('▏ ', '| '), color: p.teal, copy: '> ' }, ...inner]);
142
142
  }
143
143
  if (!compact) rows.push([]);
144
144
  continue;
@@ -159,10 +159,10 @@ export function renderMarkdown(text, width, { compact = false } = {}) {
159
159
  const done = !!task && /[xX]/.test(task[1]);
160
160
  let marker;
161
161
  if (task) {
162
- marker = { t: done ? g('✔ ', '[x] ') : g('○ ', '[ ] '), color: done ? p.green : p.dim };
162
+ marker = { t: done ? g('✔ ', '[x] ') : g('○ ', '[ ] '), color: done ? p.green : p.dim, copy: done ? '- [x] ' : '- [ ] ' };
163
163
  content = task[2];
164
164
  } else if (b) {
165
- marker = { t: `${(ascii() ? ['-', '*', '+'] : ['•', '◦', '▪'])[depth % 3]} `, color: p.brand };
165
+ marker = { t: `${(ascii() ? ['-', '*', '+'] : ['•', '◦', '▪'])[depth % 3]} `, color: p.brand, copy: '- ' };
166
166
  } else {
167
167
  marker = { t: `${o[2]}. `, color: p.brand };
168
168
  }
@@ -206,6 +206,28 @@ export function renderMarkdown(text, width, { compact = false } = {}) {
206
206
 
207
207
  const contentWidth = (spans) => spans.reduce((sum, s) => sum + stringWidth(s.t), 0);
208
208
 
209
+ /** Spans cut into rows of at most `width` cells, character by character, styles kept. */
210
+ function hardWrap(spans, width) {
211
+ const rows = [[]];
212
+ let used = 0;
213
+ for (const span of spans) {
214
+ let piece = '';
215
+ for (const ch of String(span.t ?? '')) {
216
+ const cw = stringWidth(ch);
217
+ if (used + cw > width && used > 0) {
218
+ if (piece) rows[rows.length - 1].push({ ...span, t: piece });
219
+ rows.push([]);
220
+ piece = '';
221
+ used = 0;
222
+ }
223
+ piece += ch;
224
+ used += cw;
225
+ }
226
+ if (piece) rows[rows.length - 1].push({ ...span, t: piece });
227
+ }
228
+ return rows.map((r) => (r.length ? r : [{ t: '' }]));
229
+ }
230
+
209
231
  /**
210
232
  * A fenced block: a language tag, a tinted body, and a left gutter that makes the code
211
233
  * a distinct object on the page without the cost of a full border (which would eat two
@@ -214,14 +236,14 @@ const contentWidth = (spans) => spans.reduce((sum, s) => sum + stringWidth(s.t),
214
236
  function codeBlock(body, language, width) {
215
237
  const p = palette();
216
238
  const rows = [];
217
- const gutter = { t: g('▐ ', '| '), color: p.tealDeep };
239
+ const gutter = { t: g('▐ ', '| '), color: p.tealDeep, copy: '' };
218
240
  const label = language && isKnownLanguage(language) ? language : language || '';
219
241
  const inner = Math.max(8, width - 2);
220
242
 
221
243
  rows.push([
222
- { t: g('▛', '+'), color: p.tealDeep },
223
- { t: label ? `${g('─', '-')} ${label} ` : g('─', '-'), color: p.dim },
224
- { t: g('─', '-').repeat(Math.max(0, width - 2 - (label ? label.length + 3 : 1))), color: p.lineSoft },
244
+ { t: g('▛', '+'), color: p.tealDeep, copy: '' },
245
+ { t: label ? `${g('─', '-')} ${label} ` : g('─', '-'), color: p.dim, copy: '' },
246
+ { t: g('─', '-').repeat(Math.max(0, width - 2 - (label ? label.length + 3 : 1))), color: p.lineSoft, copy: '' },
225
247
  ]);
226
248
 
227
249
  let state = {};
@@ -231,11 +253,13 @@ function codeBlock(body, language, width) {
231
253
  state = result.state;
232
254
  const spans = result.spans.length ? result.spans : [{ t: '' }];
233
255
  // Code is not prose: wrapping it on spaces would move a token onto a line where it
234
- // does not belong. Long lines are cut with a marker instead, which is honest about
235
- // there being more and keeps the block rectangular.
236
- rows.push([gutter, ...truncateRow(spans, inner, g('›', '>'))]);
256
+ // does not belong — so a long line is cut at the edge, character by character, and
257
+ // carries on under a fainter gutter. It used to be cut off with › and the rest was
258
+ // gone: a long command copied out of an answer came back truncated.
259
+ const pieces = hardWrap(spans, inner);
260
+ pieces.forEach((piece, k) => rows.push(k === 0 ? [gutter, ...piece] : [{ ...gutter, t: g('┆ ', ': '), color: p.faint }, { t: '', soft: '' }, ...piece]));
237
261
  }
238
- rows.push([{ t: g('▙', '+'), color: p.tealDeep }, { t: g('─', '-').repeat(Math.max(0, width - 2)), color: p.lineSoft }]);
262
+ rows.push([{ t: g('▙', '+'), color: p.tealDeep, copy: '' }, { t: g('─', '-').repeat(Math.max(0, width - 2)), color: p.lineSoft, copy: '' }]);
239
263
  return rows;
240
264
  }
241
265
 
@@ -265,7 +289,8 @@ function table(block, width) {
265
289
  const header = index === 0;
266
290
  const spans = [];
267
291
  for (let col = 0; col < columns; col++) {
268
- if (col) spans.push({ t: g(' │ ', ' | '), color: p.line });
292
+ // Copied as a tab: a table pasted into a sheet lands in cells.
293
+ if (col) spans.push({ t: g(' │ ', ' | '), color: p.line, copy: '\t' });
269
294
  const content = truncateRow(inlineSpans(row[col] || '', header ? { bold: true, color: p.tealSoft } : {}), widths[col]);
270
295
  spans.push(...content);
271
296
  const gap = widths[col] - contentWidth(content);
@@ -273,7 +298,7 @@ function table(block, width) {
273
298
  }
274
299
  rows.push(spans);
275
300
  if (header) {
276
- rows.push(widths.flatMap((w, col) => (col ? [{ t: g('─┼─', '-+-'), color: p.line }, { t: g('─', '-').repeat(w), color: p.line }] : [{ t: g('─', '-').repeat(w), color: p.line }])));
301
+ rows.push(widths.flatMap((w, col) => (col ? [{ t: g('─┼─', '-+-'), color: p.line, copy: '' }, { t: g('─', '-').repeat(w), color: p.line, copy: '' }] : [{ t: g('─', '-').repeat(w), color: p.line, copy: '' }])));
277
302
  }
278
303
  });
279
304
  return rows;