This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Auto-rerender on embedded PTY activity

Nathan Herald (Apr 22, 2026, 12:18 AM +0200) ed5ab991 7ca38040

+37 -1
+1
CHANGELOG.md
··· 8 8 - Add integration coverage for both installers: a real `systemctl --user` install/uninstall test and a runit install test that boots the generated service under a private `runsvdir`. 9 9 10 10 ### TUI framework 11 + - **Automatic re-render on embedded PTY data arrival.** `PtyHandle` now carries a reactive `rev` signal that bumps on every data/exit/resize/theme event, and `ptyView()`'s render path reads it — so the framework's `effect()` re-renders automatically whenever the embedded terminal content changes. Previously the consumer had to wire `handle.onActivity` to a signal bump manually; forgetting to meant output appeared to "buffer" until some unrelated signal read triggered a render (e.g., a keystroke forcing a cursor-position update). `onActivity` remains as an escape hatch for non-render side effects (stats, notifications) but is no longer required for the common case. Non-breaking. 11 12 - **Focus manager.** `createFocusManager()` + `ctx.focus` on every `ScreenContext`. Stack-based: `push(scope)` returns a disposer, `dispatchKey` / `dispatchMouse` walk innermost → outermost with bubbling (return `false` to let the parent scope try). Scopes can be conditionally active via an `active: () => boolean` predicate — useful for keeping sibling scopes alive (like one per pane) and switching which one dispatches without pushing/popping. Solves the "nested panes / modals / overlays" routing problem that every app was re-implementing by hand. The playground now uses it: global pane-switch chords via `AppConfig.onKey` (they intercept before any scope so modals can't swallow them); pane scopes in the focus stack with `active: () => pane.get() === ...`. Modal overlays just push a scope on top and dispose it when closed. `FocusManager`, `FocusScope` exported from `@myobie/pty/tui`. 12 13 - **Mouse support.** Set `AppConfig.mouse: true` to enable SGR mouse reporting. Screens get an optional `handleMouse(event, ctx)` alongside `handleKey`. `MouseEvent` carries `action` (`press` / `release` / `drag` / `move` / `scrollUp` / `scrollDown`), `button` (left/middle/right/none), 0-based `x`/`y`, and modifier flags. `hitTest(roots, x, y)` walks the rendered tree by `_rect` to find the deepest node under a point; `findInPath` locates a typed ancestor. Widgets that benefit from mouse — `virtual-list` (click + scroll), `stream-view` (scroll), `tabs` (click to select) — gained `handleVirtualMouse` / `handleStreamMouse` / `handleTabsMouse` helpers. `parseInput(buf)` returns the unified `InputEvent` stream; the legacy `parseKey(buf)` filters it to keyboard-only so existing consumers are unaffected. 13 14 - **Cursor rendering via `inverse`.** `text()` gained optional `inverse` and `background` style flags. Text widgets (`renderFieldText` / `renderTextArea`) render the cursor by painting the character under it with fg/bg swapped instead of inserting a block glyph that shifts neighbors sideways. New `renderFieldNodes(text, cursor, active, opts)` returns the three text nodes (before / inverse / after) for composing custom fields.
+20
src/tui/builders.ts
··· 11 11 import type { BoxStyle, Theme } from "./colors.ts"; 12 12 import type { ScrollRegion } from "./scrollable.ts"; 13 13 import type { TextInputState } from "./text-input.ts"; 14 + import { signal } from "./signals.ts"; 14 15 import { createRequire } from "node:module"; 15 16 const require = createRequire(import.meta.url); 16 17 ··· 473 474 474 475 let exited = false; 475 476 let dirty = false; 477 + // Reactive revision bumped on every state change that could alter the 478 + // rendered view. `ptyView()` reads this during render so the framework's 479 + // effect() rewires automatically whenever the terminal content changes. 480 + const rev = signal(0); 481 + const bumpRev = () => rev.set(rev.peek() + 1); 476 482 477 483 proc.onData((data: string) => { 478 484 terminal.write(data, () => { 479 485 dirty = true; 486 + bumpRev(); 480 487 handle.onActivity?.(); 481 488 }); 482 489 }); 483 490 proc.onExit(() => { 484 491 exited = true; 485 492 dirty = true; 493 + bumpRev(); 486 494 handle.onActivity?.(); 487 495 }); 488 496 ··· 496 504 proc.resize(newCols, newRows); 497 505 terminal.resize(newCols, newRows); 498 506 dirty = true; 507 + bumpRev(); 499 508 } 500 509 }, 501 510 ··· 518 527 setTheme(t: Theme) { 519 528 terminal.options.theme = themeToXterm(t); 520 529 dirty = true; 530 + bumpRev(); 521 531 }, 522 532 523 533 cols, ··· 526 536 get dirty() { return dirty; }, 527 537 set dirty(v: boolean) { dirty = v; }, 528 538 onActivity: null, 539 + rev, 529 540 get cursorRow() { return terminal.buffer.active.cursorY; }, 530 541 get cursorCol() { return terminal.buffer.active.cursorX; }, 531 542 get mouseMode() { return mouseMode; }, ··· 624 635 let exited = false; 625 636 let exitCode: number | null = null; 626 637 let dirty = false; 638 + // See createPty for rationale — same pattern applies to attached sessions. 639 + const rev = signal(0); 640 + const bumpRev = () => rev.set(rev.peek() + 1); 627 641 const reader = new PacketReader(); 628 642 629 643 const handle: PtyHandle = { ··· 636 650 socket.write(encodeResize(newRows, newCols)); 637 651 terminal.resize(newCols, newRows); 638 652 dirty = true; 653 + bumpRev(); 639 654 } 640 655 }, 641 656 ··· 658 673 setTheme(t: Theme) { 659 674 terminal.options.theme = themeToXterm(t); 660 675 dirty = true; 676 + bumpRev(); 661 677 }, 662 678 663 679 cols, ··· 666 682 get dirty() { return dirty; }, 667 683 set dirty(v: boolean) { dirty = v; }, 668 684 onActivity: null, 685 + rev, 669 686 get cursorRow() { return terminal.buffer.active.cursorY; }, 670 687 get cursorCol() { return terminal.buffer.active.cursorX; }, 671 688 get mouseMode() { return mouseMode; }, ··· 688 705 terminal.reset(); 689 706 terminal.write(packet.payload.toString()); 690 707 dirty = true; 708 + bumpRev(); 691 709 handle.onActivity?.(); 692 710 break; 693 711 case MessageType.DATA: 694 712 terminal.write(packet.payload.toString(), () => { 695 713 dirty = true; 714 + bumpRev(); 696 715 handle.onActivity?.(); 697 716 }); 698 717 break; ··· 700 719 exitCode = packet.payload.readInt32BE(0); 701 720 exited = true; 702 721 dirty = true; 722 + bumpRev(); 703 723 handle.onActivity?.(); 704 724 break; 705 725 }
+10 -1
src/tui/nodes.ts
··· 275 275 exited: boolean; 276 276 /** True when the PTY has received new data since the last render. Cleared by the consumer. */ 277 277 dirty: boolean; 278 - /** Optional callback fired when the PTY receives data. Used for event-driven rendering. */ 278 + /** Optional callback fired when the PTY receives data. Still supported as 279 + * an escape hatch for consumers that want to react to activity beyond 280 + * re-rendering — but you no longer need to wire it up just to get the 281 + * framework to re-render. Reading `rev.get()` during render (which 282 + * `ptyView()` does automatically) subscribes the reactive effect. */ 279 283 onActivity: (() => void) | null; 284 + /** Reactive revision counter. Bumps on every child-process data arrival, 285 + * exit, resize, and theme change. `ptyView()` reads this during render 286 + * so the framework's `effect()` re-renders automatically whenever the 287 + * terminal content changes. Consumers shouldn't write to this. */ 288 + rev: import("./signals.ts").Signal<number>; 280 289 /** Update the xterm terminal's color theme. Call when the app theme changes. */ 281 290 setTheme(theme: import("./colors.ts").Theme): void; 282 291 /** Cursor row position (0-indexed, relative to viewport). */
+6
src/tui/screen.ts
··· 699 699 node._lastCols = rect.width; 700 700 node._lastRows = rect.height; 701 701 } 702 + // Subscribe the enclosing effect() to the handle's revision signal 703 + // so the next data arrival / exit / resize / theme change triggers 704 + // a re-render automatically. Without this, output from the embedded 705 + // pty would "buffer" until some OTHER signal read forced a render 706 + // (like a keypress), because nothing else in this path is tracked. 707 + node.handle.rev.get(); 702 708 // Read xterm cells directly — no serialize round-trip 703 709 const ptyCells = node.handle.readCells(); 704 710 for (let r = 0; r < Math.min(ptyCells.length, rect.height); r++) {