@yanzc/claude-pro 0.5.6 → 0.5.8

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/ui/screen.js CHANGED
@@ -43,6 +43,16 @@ const FULL_KEEP = 20_000; // fullscreen: lines of the conversation kept to scrol
43
43
  const ALT_ON = '\x1b[?1049h\x1b[?1002h\x1b[?1006h';
44
44
  const ALT_OFF = '\x1b[?1002l\x1b[?1006l\x1b[?1049l';
45
45
  const MULTI_CLICK_MS = 450;
46
+ // A search (ctrl+f): the matches, the current one, and how many are looked for at most.
47
+ const FIND_MATCH = '\x1b[30;43m';
48
+ const FIND_CURRENT = '\x1b[30;48;5;208m';
49
+ const FIND_MAX = 10_000;
50
+ // A row that fills the rest of its width with its background (the user's messages, see renderUserMessage) ends in an
51
+ // erase to the end of the line made while that background is on. Nothing may erase after it: an erase does not move
52
+ // the cursor, so a second one, made after the colours are reset, would wipe the band out again from the same column.
53
+ const FILL = '\x1b[K';
54
+ const LEAD = /^(?:\x1b\[[0-9;]*m)+/; // the colours a row starts in
55
+ const endRow = (row) => row + (row.includes(FILL) ? '\x1b[0m' : '\x1b[0m\x1b[K');
46
56
 
47
57
  // The part of `plain` (no escape codes) between columns `from` (included) and `to` (not).
48
58
  function sliceCols(plain, from, to) {
@@ -146,6 +156,8 @@ export class Screen {
146
156
  this.lastArea = 0; // fullscreen: rows the conversation had on screen at the last draw
147
157
  this.viewMap = []; // fullscreen: what each row of the conversation area shows, {line, sub} (null: blank)
148
158
  this.barRow = null; // fullscreen: the row of the "Jump to bottom" bar, when it is shown
159
+ this.finding = null; // fullscreen: a search in the conversation (ctrl+f), see openFind
160
+ this.dropped = 0; // fullscreen: lines dropped from the top of the history so far (the first one's number)
149
161
  this.selection = null; // fullscreen: {anchor, focus}, points {line, sub, col} of the conversation (history line, wrapped row, column)
150
162
  this.dragging = false;
151
163
  this.lastClick = { at: 0, x: 0, y: 0, count: 0 };
@@ -223,7 +235,14 @@ export class Screen {
223
235
 
224
236
  wrappedAt(i, cols) {
225
237
  if (this.wrapCols !== cols) { this.wrapped = []; this.wrapCols = cols; this.selection = null; } // its rows are other rows now
226
- return (this.wrapped[i] ??= wrapLine(this.history[i], Math.max(1, cols - 1)));
238
+ if (this.wrapped[i]) return this.wrapped[i];
239
+ let rows = wrapLine(this.history[i], Math.max(1, cols - 1));
240
+ // a band now wider than the window: each of its rows starts in its colours and fills the rest of the row
241
+ if (rows.length > 1 && this.history[i].includes(FILL)) {
242
+ const lead = LEAD.exec(this.history[i])?.[0] || '';
243
+ rows = rows.map((r, k) => (k ? lead : '') + r.replaceAll(FILL, '') + lead + FILL);
244
+ }
245
+ return (this.wrapped[i] = rows);
227
246
  }
228
247
 
229
248
  // Rows of the whole conversation at this width (for the scrolling to stop at its top).
@@ -270,11 +289,166 @@ export class Screen {
270
289
  return to > from ? [from, to] : null;
271
290
  }
272
291
 
292
+ // A row of the conversation as drawn: the selection in reverse video, the matches of a search highlighted (the
293
+ // current one apart). Such a row loses its own colours, except the band of a row that fills its width.
273
294
  paintRow(e) {
274
- const cols = this.selectedCols(e);
275
- if (!cols) return e.text;
276
- const plain = stripAnsi(e.text);
277
- return sliceCols(plain, 0, cols[0]) + '\x1b[7m' + sliceCols(plain, cols[0], cols[1]) + '\x1b[27m' + sliceCols(plain, cols[1], Infinity);
295
+ const sel = this.selectedCols(e);
296
+ const hits = this.finding?.byRow.get(`${e.line}:${e.sub}`);
297
+ if (!sel && !hits) return e.text;
298
+ const fill = e.text.includes(FILL);
299
+ const base = fill ? LEAD.exec(e.text)?.[0] || '' : '';
300
+ const style = (col) => {
301
+ if (sel && col >= sel[0] && col < sel[1]) return '\x1b[7m';
302
+ const hit = hits?.find((h) => col >= h.from && col < h.to);
303
+ return !hit ? base : hit.match === this.finding.matches[this.finding.index] ? FIND_CURRENT : FIND_MATCH;
304
+ };
305
+ let out = '';
306
+ let open = '';
307
+ let col = 0;
308
+ for (const g of graphemes(stripAnsi(e.text))) {
309
+ const s = style(col);
310
+ if (s !== open) { out += '\x1b[0m' + s; open = s; }
311
+ out += g;
312
+ col += graphemeWidth(g);
313
+ }
314
+ if (fill) return out + '\x1b[0m' + base + FILL;
315
+ return out + (open ? '\x1b[0m' : '');
316
+ }
317
+
318
+ // ------------------------------------------------------------ finding text (fullscreen, ctrl+f)
319
+ //
320
+ // `finding`: {query, matches, byRow, index, anchor, key}. Matches are newest first (from the bottom of the
321
+ // conversation up): {abs, offset, line, sub, col, fromEnd}, `abs` the line counted from the first line ever
322
+ // printed (so that it survives lines dropped from the top), `offset` where it starts in the line's text, and
323
+ // `fromEnd` the rows from its first row to the end of the conversation. `byRow` maps "line:sub" to the column
324
+ // ranges to highlight, {from, to, match}. A match may go on over the rows a long line was wrapped onto.
325
+
326
+ // Start finding: the first match shown is the nearest one above the bottom of the view as it is now.
327
+ openFind() {
328
+ this.takeStatic();
329
+ this.finding = { query: '', matches: [], byRow: new Map(), index: -1, anchor: this.scroll, cols: 0, dropped: -1, scanned: -1 };
330
+ }
331
+
332
+ closeFind() {
333
+ if (!this.finding) return;
334
+ this.finding = null;
335
+ this.draw();
336
+ }
337
+
338
+ // Look for `query` as typed, in any case; shows the first match (see openFind). {index, total}: index is 0 for
339
+ // the newest match.
340
+ setFind(query) {
341
+ const f = this.finding;
342
+ if (!f) return null;
343
+ f.query = query;
344
+ f.cols = 0; // all of it again
345
+ f.index = -1;
346
+ this.refreshFind();
347
+ if (f.matches.length) {
348
+ const at = f.matches.findIndex((m) => m.fromEnd >= f.anchor);
349
+ f.index = at >= 0 ? at : f.matches.length - 1;
350
+ this.revealMatch();
351
+ }
352
+ this.draw();
353
+ return this.findStatus();
354
+ }
355
+
356
+ // The next match up (`dir` 1: older) or down (-1: newer), round from either end.
357
+ findStep(dir) {
358
+ const f = this.finding;
359
+ if (!f) return null;
360
+ this.refreshFind();
361
+ const n = f.matches.length;
362
+ if (n) {
363
+ f.index = ((f.index < 0 ? 0 : f.index + dir) % n + n) % n;
364
+ this.revealMatch();
365
+ }
366
+ this.draw();
367
+ return this.findStatus();
368
+ }
369
+
370
+ findStatus() {
371
+ const f = this.finding;
372
+ if (!f) return null;
373
+ this.refreshFind();
374
+ return { query: f.query, index: f.index, total: f.matches.length };
375
+ }
376
+
377
+ // The matches again when the conversation or the width changed since they were found; the current one stays.
378
+ // Lines added at the end (output while the search is open) are searched on their own.
379
+ refreshFind() {
380
+ const f = this.finding;
381
+ const cols = this.width;
382
+ if (!f || (f.cols === cols && f.dropped === this.dropped && f.scanned === this.history.length)) return;
383
+ if (!f.query) {
384
+ Object.assign(f, { matches: [], byRow: new Map(), index: -1, cols, dropped: this.dropped, scanned: this.history.length });
385
+ return;
386
+ }
387
+ if (f.cols === cols && f.dropped === this.dropped && f.scanned < this.history.length) {
388
+ const added = this.scanFind(f, f.scanned, this.history.length, cols);
389
+ for (const m of f.matches) m.fromEnd += added.rows;
390
+ f.matches = [...added.matches, ...f.matches];
391
+ if (f.index >= 0) f.index += added.matches.length;
392
+ f.scanned = this.history.length;
393
+ return;
394
+ }
395
+ const current = f.matches[f.index];
396
+ f.byRow = new Map();
397
+ f.matches = this.scanFind(f, 0, this.history.length, cols).matches;
398
+ Object.assign(f, { cols, dropped: this.dropped, scanned: this.history.length });
399
+ const same = current ? f.matches.findIndex((m) => m.abs === current.abs && m.offset === current.offset) : -1;
400
+ f.index = same >= 0 ? same : Math.min(f.index, f.matches.length - 1);
401
+ }
402
+
403
+ // The matches of f.query in history lines [from, to), newest first, entered in f.byRow; `rows`: the rows those
404
+ // lines take (fromEnd counts from the end of line to - 1).
405
+ scanFind(f, from, to, cols) {
406
+ const re = new RegExp(f.query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'giu');
407
+ const matches = [];
408
+ let rowsAfter = 0;
409
+ for (let line = to - 1; line >= from; line--) {
410
+ const rows = this.wrappedAt(line, cols);
411
+ if (matches.length < FIND_MAX) {
412
+ const plains = rows.map((r) => stripAnsi(r));
413
+ const text = plains.join('');
414
+ const starts = [];
415
+ for (let k = 0, at = 0; k < plains.length; at += plains[k].length, k++) starts.push(at);
416
+ // offset -> {sub, col}: the row it falls on, and the column there (`end`: an end offset belongs to the row before)
417
+ const place = (offset, end = false) => {
418
+ let sub = plains.length - 1;
419
+ while (sub > 0 && (end ? starts[sub] >= offset : starts[sub] > offset)) sub--;
420
+ return { sub, col: stringWidth(plains[sub].slice(0, offset - starts[sub])) };
421
+ };
422
+ const found = [];
423
+ for (const m of text.matchAll(re)) {
424
+ if (!m[0]) continue;
425
+ const a = place(m.index);
426
+ const b = place(m.index + m[0].length, true);
427
+ const match = { abs: this.dropped + line, offset: m.index, line, sub: a.sub, col: a.col, fromEnd: rowsAfter + rows.length - 1 - a.sub };
428
+ for (let sub = a.sub; sub <= b.sub; sub++) {
429
+ const k = `${line}:${sub}`;
430
+ if (!f.byRow.has(k)) f.byRow.set(k, []);
431
+ f.byRow.get(k).push({ from: sub === a.sub ? a.col : 0, to: sub === b.sub ? b.col : Infinity, match });
432
+ }
433
+ found.push(match);
434
+ }
435
+ matches.push(...found.reverse());
436
+ }
437
+ rowsAfter += rows.length;
438
+ }
439
+ return { matches, rows: rowsAfter };
440
+ }
441
+
442
+ // Scroll so that the current match is on screen: where it is if it is already in view, else in the middle.
443
+ revealMatch() {
444
+ const m = this.finding?.matches[this.finding.index];
445
+ if (!m || !this.full) return;
446
+ const area = Math.max(1, this.lastArea || this.height - 6);
447
+ const lowest = this.scroll + (this.scroll > 0 ? 1 : 0); // the "Jump to bottom" bar covers the last row
448
+ if (m.fromEnd >= lowest && m.fromEnd < this.scroll + area) return;
449
+ const max = Math.max(0, this.totalRows(this.width) - area);
450
+ this.scroll = Math.max(0, Math.min(max, m.fromEnd - Math.floor(area / 2)));
451
+ if (!this.scroll) this.unseen = 0;
278
452
  }
279
453
 
280
454
  // The text selected, as it was printed: rows a long line was wrapped onto are joined again.
@@ -407,6 +581,7 @@ export class Screen {
407
581
  const layout = this.layoutLive(cols);
408
582
  const area = Math.max(0, screenRows - layout.rows.length);
409
583
  this.lastArea = area;
584
+ if (this.finding) this.refreshFind();
410
585
  const shown = this.view(area, cols);
411
586
  this.viewMap = shown;
412
587
  const top = shown.map((e) => (e ? this.paintRow(e) : ''));
@@ -416,7 +591,7 @@ export class Screen {
416
591
  let buf = SYNC_START + ESC.hideCursor + this.titleUpdate();
417
592
  if (full) { buf += '\x1b[2J'; this.frame = []; }
418
593
  frame.forEach((row, i) => {
419
- if (this.frame[i] !== row) buf += `\x1b[${i + 1};1H` + row + '\x1b[0m\x1b[K';
594
+ if (this.frame[i] !== row) buf += `\x1b[${i + 1};1H` + endRow(row);
420
595
  });
421
596
  if (frame.length < this.frame.length) buf += `\x1b[${frame.length + 1};1H` + ESC.clearDown;
422
597
  this.frame = frame;
@@ -474,6 +649,7 @@ export class Screen {
474
649
  const drop = this.history.length - keep;
475
650
  this.history.splice(0, drop);
476
651
  this.wrapped.splice(0, drop);
652
+ this.dropped += drop;
477
653
  if (this.selection) {
478
654
  for (const p of [this.selection.anchor, this.selection.focus]) p.line -= drop;
479
655
  if (this.selection.anchor.line < 0 || this.selection.focus.line < 0) this.selection = null;
@@ -585,7 +761,7 @@ export class Screen {
585
761
  }
586
762
  const all = [...tail, ...layout.rows];
587
763
  let buf = SYNC_START + ESC.hideCursor + this.titleUpdate() + '\x1b[H';
588
- buf += all.map((row) => row + '\x1b[0m\x1b[K').join('\r\n');
764
+ buf += all.map(endRow).join('\r\n');
589
765
  buf += ESC.clearDown;
590
766
  buf += this.settleCursor(layout);
591
767
  buf += SYNC_END;
@@ -630,6 +806,7 @@ export class Screen {
630
806
 
631
807
  clearScreen() {
632
808
  if (this.full) {
809
+ this.dropped += this.history.length;
633
810
  this.history = [];
634
811
  this.wrapped = [];
635
812
  this.selection = null;