@kolisachint/hoocode-tui 0.5.68 → 0.5.71

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/tui.js CHANGED
@@ -284,6 +284,35 @@ export class TUI extends Container {
284
284
  previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
285
285
  fullRedrawCount = 0;
286
286
  stopped = false;
287
+ /**
288
+ * The filler that keeps the app the size of the screen.
289
+ *
290
+ * ## Why the app is full-screen at all
291
+ *
292
+ * This renderer appends: a frame is the whole component tree flattened into
293
+ * a line buffer, written from wherever the cursor happens to be. On a fresh
294
+ * session that buffer is a dozen rows, so the banner sat halfway up a
295
+ * terminal with the prompt under it and forty rows of the user's shell
296
+ * history above — and the prompt walked down the screen as the conversation
297
+ * grew, only reaching the bottom row once the session was long enough to
298
+ * scroll. Two different layouts for the same app, and the one you meet first
299
+ * is the one that does not look like an app.
300
+ *
301
+ * ## What this does
302
+ *
303
+ * Before the frame is diffed, the root measures it and gives the leftover
304
+ * rows to one designated child. The buffer is therefore never shorter than
305
+ * the terminal, so the terminal's last row is always the buffer's last row:
306
+ * the header stays at the top, the prompt and the footer stay on the bottom,
307
+ * and everything between them is conversation. Nothing else changes — this
308
+ * is still the normal screen, so scrollback, selection and search all still
309
+ * work, and the session is still on screen after you quit.
310
+ *
311
+ * Set to `undefined` (no flex child) and the old append-only behaviour is
312
+ * exactly what you get back, which is what the tests that predate this and
313
+ * any embedder outside the app rely on.
314
+ */
315
+ flexSpacer;
287
316
  /**
288
317
  * The pinned viewport.
289
318
  *
@@ -362,6 +391,64 @@ export class TUI extends Container {
362
391
  setScrollStatusFormatter(formatter) {
363
392
  this.scrollStatusFormatter = formatter;
364
393
  }
394
+ /**
395
+ * Nominate the child that absorbs the leftover rows (see `flexSpacer`).
396
+ *
397
+ * It must already be a child of the root, and it should sit between the part
398
+ * of the tree that flows from the top and the chrome that hangs off the
399
+ * bottom — everything after it is what gets pinned to the foot of the screen.
400
+ */
401
+ setFlexSpacer(spacer) {
402
+ this.flexSpacer = spacer;
403
+ }
404
+ /**
405
+ * Give the flex child whatever the frame did not use, at `height` rows.
406
+ *
407
+ * Returns true when the height moved, meaning the caller has to flatten
408
+ * again — the measurement can only be made from a finished frame, so the
409
+ * frame that answers it is always the second one. Both passes are cheap
410
+ * after the first: every other child returns its memoized array untouched.
411
+ *
412
+ * Two cases, and the second one is the one with a bug behind it.
413
+ *
414
+ * **The frame fits on the screen.** Fill it out to the screen's height and
415
+ * the frame starts on the first row, which is the whole point of the fill.
416
+ *
417
+ * **The frame is taller than the screen.** Then the fill is not what puts
418
+ * the prompt on the floor — the terminal's own scroll is, and the buffer's
419
+ * last row *is* the screen's last row. So a buffer that gets *shorter*
420
+ * takes the prompt up the screen with it: the renderer clears the rows that
421
+ * came off the end and there is nothing it can do to scroll the transcript
422
+ * back down into them, because those rows are in the terminal's scrollback
423
+ * and only the terminal can move them. That is a picker closing, a
424
+ * notification fading, a task ledger emptying — the prompt stranded
425
+ * mid-screen with a band of blank rows under it, and it stays stranded
426
+ * until enough output arrives to push it back down.
427
+ *
428
+ * So the fill takes what the shrinking content gave up, which keeps the
429
+ * buffer the length it already was and the last row where it already is.
430
+ * The blank band ends up *above* the chrome instead of below it, where it
431
+ * reads as room rather than as a layout that came apart, and the next
432
+ * output to arrive lands in it rather than scrolling the screen. Capped at
433
+ * a screenful: a fill longer than the screen is rows nobody can see, and it
434
+ * is given up altogether on the frames that repaint the whole screen anyway.
435
+ */
436
+ fitFlexSpacer(lines, height, repaint = false) {
437
+ const spacer = this.flexSpacer;
438
+ if (!spacer)
439
+ return false;
440
+ const content = lines.length - spacer.currentHeight;
441
+ if (content < height)
442
+ return spacer.setHeight(height - content);
443
+ // A frame that is about to be repainted from the top of a cleared screen
444
+ // has no floor to hold: the resize already threw the old screen away, and
445
+ // holding its length would only bank a band of blank rows into the middle
446
+ // of the new one.
447
+ if (repaint)
448
+ return spacer.setHeight(0);
449
+ const held = Math.min(this.previousLines.length - content, height);
450
+ return spacer.setHeight(Math.max(0, held));
451
+ }
365
452
  /**
366
453
  * The screen rows a pinned window shows, the last one being the indicator.
367
454
  *
@@ -373,9 +460,13 @@ export class TUI extends Container {
373
460
  return Math.max(1, this.terminal.rows - 1);
374
461
  }
375
462
  /** Rows available to scroll through — the live buffer while live, the
376
- * measured one while pinned. */
463
+ * measured one while pinned. The filler is not transcript: counting it would
464
+ * let a session with nothing above the fold pin itself one row off the
465
+ * bottom and paint a screen of blanks. */
377
466
  transcriptLength() {
378
- return this.scrollOffset === null ? this.previousLines.length : this.scrollTotalLines;
467
+ if (this.scrollOffset !== null)
468
+ return this.scrollTotalLines;
469
+ return this.previousLines.length - (this.flexSpacer?.currentHeight ?? 0);
379
470
  }
380
471
  /**
381
472
  * Move the view by `delta` rows; negative is towards the start.
@@ -1502,6 +1593,10 @@ export class TUI extends Container {
1502
1593
  renderScrollView() {
1503
1594
  const width = this.terminal.columns;
1504
1595
  const height = this.terminal.rows;
1596
+ // No fill while pinned: the window is already the height of the screen,
1597
+ // and blank rows in the buffer would be rows of the transcript the reader
1598
+ // has to scroll past. The next live frame puts it back.
1599
+ this.flexSpacer?.setHeight(0);
1505
1600
  let lines = this.render(width);
1506
1601
  // A patch computed while pinned describes rows nothing painted to the
1507
1602
  // normal screen, so the live path must never be handed it.
@@ -1591,6 +1686,15 @@ export class TUI extends Container {
1591
1686
  // Render all components to get new lines. The root render() reports what
1592
1687
  // it changed via lastPatch; consume it here (it is per-frame state).
1593
1688
  let newLines = this.render(width);
1689
+ // The frame has to exist before its leftover rows can be counted, so the
1690
+ // fill is settled by re-flattening rather than predicted. The second pass
1691
+ // invalidates the first's patch — it describes the buffer from before the
1692
+ // splice — so fall back to the full scan, which is always correct and only
1693
+ // runs on frames where the content height actually changed.
1694
+ if (this.fitFlexSpacer(newLines, height, widthChanged || heightChanged)) {
1695
+ newLines = this.render(width);
1696
+ this.lastPatch = "full";
1697
+ }
1594
1698
  const patch = this.lastPatch;
1595
1699
  this.lastPatch = "full";
1596
1700
  // Composite overlays into the rendered lines (before differential compare)