This repository has no description
0

Configure Feed

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

Auto-refresh the home list while it's visible (Closes #26)

The interactive overview was a snapshot taken on screen entry — new
sessions, exits, tag changes, and rename events stayed invisible
until the user navigated away and back. Reported by
@schickling-assistant.

Implementation: poll listSessions() once a second while the home
screen is the active view, push the result through the existing
`sessions` signal. The framework's effect()-wrapped renderFrame
re-runs because it tracks signal reads — no other plumbing needed.
Polling pauses around attach / spawn handoffs (pauseApp /
resumeApp wrap myApp.pause / myApp.resume so the polling toggles
in lockstep), and the interval is .unref()-ed so the event loop
isn't held alive by it (when stdin closes the process exits
cleanly).

Tried fs.watch + EventFollower first; both were silent in the
test harness because the TUI runs in a node-pty child process and
the file changes happen in a sibling process — that combination
isn't reliably picked up on macOS. Existing EventFollower tests
all run in-process so the cross-process case wasn't covered. A 1-
second poll is responsive enough for the "did a session appear
or exit" UX, costs a single readdir + per-file stat per tick,
and works on every platform regardless of fd / IPC quirks.

Tests (tests/tui.test.ts) cover three trigger types end-to-end
through a real PTY: session created externally appears in the
list; session that exits externally shows up as exited (or its
absence is reflected); tag added externally with `pty tag`
renders inline as `#k=v`. Each waits up to 5-8s for the next
poll tick to fire.

Nathan Herald (Apr 27, 2026, 6:24 PM +0200) 0dc1c8f5 c850d661

+146 -10
+1
CHANGELOG.md
··· 3 3 ## Unreleased 4 4 5 5 ### Interactive TUI 6 + - **Home list auto-refreshes when sessions change underneath it** (closes #26). Previously the overview was a snapshot taken on screen entry — new sessions, exits, tag changes, and rename events didn't appear until the user navigated away and back. Now the home screen polls `listSessions()` once per second while it's the active view, pushing the result through the `sessions` signal so the framework's reactive render picks it up. Polling pauses while the user is attached / spawning a new session and resumes on return; the interval is `unref()`-ed so it never holds the event loop alive on its own (the TUI exits cleanly when the user quits or stdin closes). Polling was chosen over `fs.watch` / `EventFollower` because cross-process file-watching through the node-pty child layer is unreliable on macOS — polling is simpler, platform-independent, and cheap (one `readdir` + per-file `stat` per second). 6 7 - **Filter and selection persist across attach/detach** (closes #27). Previously, the overview's filter input and selection were cleared every time the user attached to a session and came back — `doAttach` (and the remote-spawn / remote-attach paths) explicitly reset `filterField` to empty on `onDetach` / `onExit`. That made jumping in and out of sessions feel disorienting. Now the four return paths preserve both. The selection still clamps when the underlying list shrinks (a session that exited and got reaped while the user was attached), so it never points off the end. The clear-filter-on-spawn behavior was the same code path; it's also gone, which means `+ Create new session...` followed by detach lands you back on your filtered view too. 7 8 8 9 ### Tags
+70 -10
src/tui/interactive.ts
··· 516 516 517 517 function doAttachRemote(host: RelayHost, session: RemoteSession): void { 518 518 if (!relayBin) return; 519 - myApp?.pause(); 519 + pauseApp(); 520 520 521 521 // Build the connect URL: base URL + /session-name 522 522 const url = host.url.replace(/#.*$/, "") + "/" + session.name + ··· 536 536 const maxIdx = Math.max(0, totalItems.get() - 1); 537 537 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); 538 538 }); 539 - myApp?.resume(); 539 + resumeApp(); 540 540 })(); 541 541 } 542 542 ··· 552 552 553 553 function doSpawnRemote(host: RelayHost, name: string): void { 554 554 if (!relayBin) return; 555 - myApp?.pause(); 555 + pauseApp(); 556 556 557 557 // Forward --filter-tag tags to the relay so the newly-spawned remote 558 558 // session is tagged and stays within the filtered view. ··· 569 569 const maxIdx = Math.max(0, totalItems.get() - 1); 570 570 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); 571 571 }); 572 - myApp?.resume(); 572 + resumeApp(); 573 573 })(); 574 574 } 575 575 576 576 function doAttach(name: string): void { 577 - myApp?.pause(); 577 + pauseApp(); 578 578 attach({ 579 579 name, 580 580 onDetach: async () => { ··· 588 588 const maxIdx = Math.max(0, totalItems.get() - 1); 589 589 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); 590 590 }); 591 - myApp?.resume(); 591 + resumeApp(); 592 592 }, 593 593 onExit: async (_code) => { 594 594 // Brief delay to let the daemon write exit metadata to disk ··· 599 599 const maxIdx = Math.max(0, totalItems.get() - 1); 600 600 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); 601 601 }); 602 - myApp?.resume(); 602 + resumeApp(); 603 603 }, 604 604 }); 605 605 } ··· 608 608 * by the one-keystroke "Create new session" path; name is a random id 609 609 * generated by the caller. */ 610 610 async function doCreate(dir: string, name: string, shell: string): Promise<void> { 611 - myApp?.pause(); 611 + pauseApp(); 612 612 613 613 // Random names come from randomSessionName() — no collision check needed, 614 614 // but we still acquireLock to guard against multi-process races during ··· 617 617 console.error(`Session "${name}" is being created by another process.`); 618 618 const updated = await listSessions(); 619 619 sessions.set(updated); 620 - myApp?.resume(); 620 + resumeApp(); 621 621 return; 622 622 } 623 623 ··· 641 641 console.error(e.message); 642 642 const updated = await listSessions(); 643 643 sessions.set(updated); 644 - myApp?.resume(); 644 + resumeApp(); 645 645 return; 646 646 } finally { 647 647 releaseLock(name); ··· 662 662 filterTags?: Record<string, string>; 663 663 } 664 664 665 + /** Auto-refresh the home list while it's visible. Closes #26. 666 + * 667 + * Polling, not fs.watch, by design: fs.watch + EventFollower turned out 668 + * to be unreliable when the TUI runs in a node-pty child process and 669 + * another process writes to the watched dir (existing EventFollower 670 + * tests all run in-process, so the cross-process case wasn't covered). 671 + * A 1s poll is responsive enough for "sessions came and went" UX, 672 + * costs a single readdir + per-file stat per tick, and works on every 673 + * platform regardless of fd / IPC quirks. 674 + * 675 + * Polling pauses while the user is attached (or the app is otherwise 676 + * paused) — see `pauseApp` / `resumeApp` below. No work happens when 677 + * the home screen isn't visible. */ 678 + const POLL_INTERVAL_MS = 1000; 679 + let pollHandle: NodeJS.Timeout | null = null; 680 + 681 + function startHomeAutoRefresh(): void { 682 + if (pollHandle) return; 683 + pollHandle = setInterval(async () => { 684 + try { 685 + const updated = await listSessions(); 686 + sessions.set(updated); 687 + } catch {} 688 + }, POLL_INTERVAL_MS); 689 + // Don't hold the event loop alive on this timer alone — when stdin 690 + // closes (user quits, or the TUI bails because there's no TTY), the 691 + // process should exit cleanly without waiting for an interval tick. 692 + // Stdin keeps the loop alive while the TUI is actually running. 693 + pollHandle.unref(); 694 + } 695 + 696 + function stopHomeAutoRefresh(): void { 697 + if (pollHandle) { 698 + clearInterval(pollHandle); 699 + pollHandle = null; 700 + } 701 + } 702 + 703 + /** Pause the app for an attach/spawn handoff. Stops home polling so we 704 + * don't burn a listSessions every second while the user can't see the 705 + * home screen. */ 706 + function pauseApp(): void { 707 + stopHomeAutoRefresh(); 708 + myApp?.pause(); 709 + } 710 + 711 + /** Resume the app after the attach/spawn child returns. Restarts polling 712 + * so the home screen stays fresh again. */ 713 + function resumeApp(): void { 714 + myApp?.resume(); 715 + startHomeAutoRefresh(); 716 + } 717 + 665 718 export async function runInteractive(options?: RunInteractiveOptions): Promise<void> { 666 719 if (options?.filterTags && Object.keys(options.filterTags).length > 0) { 667 720 filterTags.set(options.filterTags); ··· 696 749 }, 697 750 }); 698 751 752 + // Start polling the home list AFTER constructing the app; pauseApp / 753 + // resumeApp toggle this around attach/spawn so we don't poll while 754 + // the user is in another session. `myApp.start()` is non-blocking — 755 + // it registers listeners and returns; the event loop keeps running 756 + // because stdin is held open. Polling stops when the process exits 757 + // or when an explicit pauseApp() / stopHomeAutoRefresh() runs. 758 + startHomeAutoRefresh(); 699 759 myApp.start(); 700 760 }
+75
tests/tui.test.ts
··· 746 746 ); 747 747 748 748 it( 749 + "auto-refreshes the home list when a session is created externally (closes #26)", 750 + async () => { 751 + const sessionDir = makeSessionDir(); 752 + const a = uniqueName(); 753 + const bg1 = await createBackgroundSession(sessionDir, a, "sh", ["-c", "sleep 300"], os.tmpdir()); 754 + bgPids.push(bg1.pid); 755 + 756 + const tui = createTuiSession(sessionDir); 757 + await tui.waitForText(a, 10000); 758 + 759 + // While the TUI sits idle on the home screen, create a new session 760 + // out of band. The dir-watch + EventFollower wiring should pick up 761 + // session_start (or the new .json file) and refresh sessions. 762 + const b = uniqueName(); 763 + const bg2 = await createBackgroundSession(sessionDir, b, "sh", ["-c", "sleep 300"], os.tmpdir()); 764 + bgPids.push(bg2.pid); 765 + 766 + // Should appear without any keystroke from the TUI. 767 + await tui.waitForText(b, 5000); 768 + }, 769 + 20000, 770 + ); 771 + 772 + it( 773 + "auto-refreshes the home list when a session exits externally (closes #26)", 774 + async () => { 775 + const sessionDir = makeSessionDir(); 776 + const name = uniqueName(); 777 + const { pid } = await createBackgroundSession(sessionDir, name, "sh", ["-c", "sleep 300"], os.tmpdir()); 778 + bgPids.push(pid); 779 + 780 + const tui = createTuiSession(sessionDir); 781 + await tui.waitForText(name, 10000); 782 + 783 + // The "running" indicator (●) is on; force the daemon to exit and 784 + // wait for the list to reflect the change. 785 + try { process.kill(pid, "SIGTERM"); } catch {} 786 + // Filter out the killed pid from cleanup since we just killed it. 787 + bgPids = bgPids.filter((p) => p !== pid); 788 + 789 + // The session moves to the exited group — wait for the marker text 790 + // that only renders for exited sessions ("(exited ..."). 791 + await tui.waitForText("exited", 8000); 792 + }, 793 + 20000, 794 + ); 795 + 796 + it( 797 + "auto-refreshes the home list when tags change externally (closes #26)", 798 + async () => { 799 + const sessionDir = makeSessionDir(); 800 + const name = uniqueName(); 801 + const { pid } = await createBackgroundSession(sessionDir, name, "sh", ["-c", "sleep 300"], os.tmpdir()); 802 + bgPids.push(pid); 803 + 804 + const tui = createTuiSession(sessionDir); 805 + await tui.waitForText(name, 10000); 806 + // No tags rendered yet. 807 + const before = tui.screenshot(); 808 + expect(before.text).not.toContain("#role=web"); 809 + 810 + // Out-of-band tag change. EventFollower should pick up `tags_change` 811 + // and the home list should re-render with the inline tag. 812 + const r = spawn(nodeBin, [cliPath, "tag", name, "role=web"], { 813 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 814 + stdio: "ignore", 815 + }); 816 + await new Promise<void>((resolve) => r.on("exit", () => resolve())); 817 + 818 + await tui.waitForText("#role=web", 5000); 819 + }, 820 + 20000, 821 + ); 822 + 823 + it( 749 824 "preserves the filter and selection across attach/detach (closes #27)", 750 825 async () => { 751 826 const sessionDir = makeSessionDir();