···88- 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`.
991010### TUI framework
1111+- **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.
1112- **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`.
1213- **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.
1314- **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
···1111import type { BoxStyle, Theme } from "./colors.ts";
1212import type { ScrollRegion } from "./scrollable.ts";
1313import type { TextInputState } from "./text-input.ts";
1414+import { signal } from "./signals.ts";
1415import { createRequire } from "node:module";
1516const require = createRequire(import.meta.url);
1617···473474474475 let exited = false;
475476 let dirty = false;
477477+ // Reactive revision bumped on every state change that could alter the
478478+ // rendered view. `ptyView()` reads this during render so the framework's
479479+ // effect() rewires automatically whenever the terminal content changes.
480480+ const rev = signal(0);
481481+ const bumpRev = () => rev.set(rev.peek() + 1);
476482477483 proc.onData((data: string) => {
478484 terminal.write(data, () => {
479485 dirty = true;
486486+ bumpRev();
480487 handle.onActivity?.();
481488 });
482489 });
483490 proc.onExit(() => {
484491 exited = true;
485492 dirty = true;
493493+ bumpRev();
486494 handle.onActivity?.();
487495 });
488496···496504 proc.resize(newCols, newRows);
497505 terminal.resize(newCols, newRows);
498506 dirty = true;
507507+ bumpRev();
499508 }
500509 },
501510···518527 setTheme(t: Theme) {
519528 terminal.options.theme = themeToXterm(t);
520529 dirty = true;
530530+ bumpRev();
521531 },
522532523533 cols,
···526536 get dirty() { return dirty; },
527537 set dirty(v: boolean) { dirty = v; },
528538 onActivity: null,
539539+ rev,
529540 get cursorRow() { return terminal.buffer.active.cursorY; },
530541 get cursorCol() { return terminal.buffer.active.cursorX; },
531542 get mouseMode() { return mouseMode; },
···624635 let exited = false;
625636 let exitCode: number | null = null;
626637 let dirty = false;
638638+ // See createPty for rationale — same pattern applies to attached sessions.
639639+ const rev = signal(0);
640640+ const bumpRev = () => rev.set(rev.peek() + 1);
627641 const reader = new PacketReader();
628642629643 const handle: PtyHandle = {
···636650 socket.write(encodeResize(newRows, newCols));
637651 terminal.resize(newCols, newRows);
638652 dirty = true;
653653+ bumpRev();
639654 }
640655 },
641656···658673 setTheme(t: Theme) {
659674 terminal.options.theme = themeToXterm(t);
660675 dirty = true;
676676+ bumpRev();
661677 },
662678663679 cols,
···666682 get dirty() { return dirty; },
667683 set dirty(v: boolean) { dirty = v; },
668684 onActivity: null,
685685+ rev,
669686 get cursorRow() { return terminal.buffer.active.cursorY; },
670687 get cursorCol() { return terminal.buffer.active.cursorX; },
671688 get mouseMode() { return mouseMode; },
···688705 terminal.reset();
689706 terminal.write(packet.payload.toString());
690707 dirty = true;
708708+ bumpRev();
691709 handle.onActivity?.();
692710 break;
693711 case MessageType.DATA:
694712 terminal.write(packet.payload.toString(), () => {
695713 dirty = true;
714714+ bumpRev();
696715 handle.onActivity?.();
697716 });
698717 break;
···700719 exitCode = packet.payload.readInt32BE(0);
701720 exited = true;
702721 dirty = true;
722722+ bumpRev();
703723 handle.onActivity?.();
704724 break;
705725 }
+10-1
src/tui/nodes.ts
···275275 exited: boolean;
276276 /** True when the PTY has received new data since the last render. Cleared by the consumer. */
277277 dirty: boolean;
278278- /** Optional callback fired when the PTY receives data. Used for event-driven rendering. */
278278+ /** Optional callback fired when the PTY receives data. Still supported as
279279+ * an escape hatch for consumers that want to react to activity beyond
280280+ * re-rendering — but you no longer need to wire it up just to get the
281281+ * framework to re-render. Reading `rev.get()` during render (which
282282+ * `ptyView()` does automatically) subscribes the reactive effect. */
279283 onActivity: (() => void) | null;
284284+ /** Reactive revision counter. Bumps on every child-process data arrival,
285285+ * exit, resize, and theme change. `ptyView()` reads this during render
286286+ * so the framework's `effect()` re-renders automatically whenever the
287287+ * terminal content changes. Consumers shouldn't write to this. */
288288+ rev: import("./signals.ts").Signal<number>;
280289 /** Update the xterm terminal's color theme. Call when the app theme changes. */
281290 setTheme(theme: import("./colors.ts").Theme): void;
282291 /** Cursor row position (0-indexed, relative to viewport). */
+6
src/tui/screen.ts
···699699 node._lastCols = rect.width;
700700 node._lastRows = rect.height;
701701 }
702702+ // Subscribe the enclosing effect() to the handle's revision signal
703703+ // so the next data arrival / exit / resize / theme change triggers
704704+ // a re-render automatically. Without this, output from the embedded
705705+ // pty would "buffer" until some OTHER signal read forced a render
706706+ // (like a keypress), because nothing else in this path is tracked.
707707+ node.handle.rev.get();
702708 // Read xterm cells directly — no serialize round-trip
703709 const ptyCells = node.handle.readCells();
704710 for (let r = 0; r < Math.min(ptyCells.length, rect.height); r++) {