This repository has no description
0

Configure Feed

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

Preserve filter and selection across attach/detach in the interactive overview (Closes #27)

Filing in / out of sessions cleared the overview's filter on every
return (and reset the selection), which made navigating multiple
sessions feel disorienting per @schickling-assistant's report.
Mechanism: doAttach's onDetach/onExit handlers, doAttachRemote's
post-attach refresh, and doSpawnRemote's post-spawn refresh all
explicitly called filterField.set({ text: "", cursor: 0 }) before
resuming the app. Removing those clears makes the module-level
signals naturally persist across the attach round-trip.

The selection clamp logic is intentionally kept — when the list
shrinks (e.g., the session you were attached to exits and gets
reaped before you return), selectedIndex still snaps in-bounds via
the existing Math.max(0, totalItems - 1) check. That's the right
behavior either way and is independent of the filter-preservation
fix.

Test (tests/tui.test.ts) creates three sessions, types a filter
that narrows to one, attaches to it, detaches, and asserts: the
"Filter: ..." line still shows the typed value, the matched
session is still visible, the other two are still hidden by the
filter. Then ctrl+u clears it and the other two reappear, which
also confirms the filter cursor is preserved (ctrl+u operates on
the live cursor position).

Nathan Herald (Apr 27, 2026, 6:01 PM +0200) c850d661 9a6b33c4

+72 -5
+3
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Interactive TUI 6 + - **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 + 5 8 ### Tags 6 9 - **`pty tag-multi` — multi-session tag operations.** New sibling command for read and write across many sessions in one CLI invocation. Three mutually-exclusive selectors: 7 10 - `<name>...` — explicit list (resolved up-front; if any name is unresolvable the command aborts before any write so partial application doesn't happen)
+6 -5
src/tui/interactive.ts
··· 526 526 stdio: "inherit", 527 527 }); 528 528 529 - // Refresh and resume 529 + // Refresh and resume. Preserve the filter and (when in-bounds) the 530 + // selection so the user lands back where they were — closes #27. 530 531 (async () => { 531 532 const updated = await listSessions(); 532 533 refreshRelayHosts(); 533 534 batch(() => { 534 535 sessions.set(updated); 535 - filterField.set({ text: "", cursor: 0 }); 536 536 const maxIdx = Math.max(0, totalItems.get() - 1); 537 537 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); 538 538 }); ··· 566 566 refreshRelayHosts(); 567 567 batch(() => { 568 568 sessions.set(updated); 569 - filterField.set({ text: "", cursor: 0 }); 570 569 const maxIdx = Math.max(0, totalItems.get() - 1); 571 570 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); 572 571 }); ··· 579 578 attach({ 580 579 name, 581 580 onDetach: async () => { 581 + // Preserve filter + in-bounds selection so the user returns to the 582 + // overview where they were. Closes #27. The selection clamps when 583 + // the list shrunk (the attached session might have exited and been 584 + // gc'd while we were attached). 582 585 const updated = await listSessions(); 583 586 batch(() => { 584 587 sessions.set(updated); 585 - filterField.set({ text: "", cursor: 0 }); 586 588 const maxIdx = Math.max(0, totalItems.get() - 1); 587 589 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); 588 590 }); ··· 594 596 const updated = await listSessions(); 595 597 batch(() => { 596 598 sessions.set(updated); 597 - filterField.set({ text: "", cursor: 0 }); 598 599 const maxIdx = Math.max(0, totalItems.get() - 1); 599 600 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); 600 601 });
+63
tests/tui.test.ts
··· 746 746 ); 747 747 748 748 it( 749 + "preserves the filter and selection across attach/detach (closes #27)", 750 + async () => { 751 + const sessionDir = makeSessionDir(); 752 + // Three sessions whose names share enough characters that a partial 753 + // filter narrows but doesn't empty the list. 754 + const a = uniqueName(); 755 + const b = uniqueName(); 756 + const c = uniqueName(); 757 + const bg1 = await createBackgroundSession(sessionDir, a, "sh", ["-c", "echo A_MARK; sleep 300"], os.tmpdir()); 758 + bgPids.push(bg1.pid); 759 + const bg2 = await createBackgroundSession(sessionDir, b, "sh", ["-c", "sleep 300"], os.tmpdir()); 760 + bgPids.push(bg2.pid); 761 + const bg3 = await createBackgroundSession(sessionDir, c, "sh", ["-c", "sleep 300"], os.tmpdir()); 762 + bgPids.push(bg3.pid); 763 + 764 + const tui = createTuiSession(sessionDir); 765 + 766 + // Wait for all three to appear so the filter has something to filter. 767 + await tui.waitForText(a, 10000); 768 + await tui.waitForText(b, 10000); 769 + await tui.waitForText(c, 10000); 770 + 771 + // Type a filter that matches `a` only — shared name prefix `s` plus a 772 + // chunk of `a`'s random suffix. 773 + const filter = a.slice(-4); 774 + tui.type(filter); 775 + // Wait for the OTHER sessions to disappear so we're confident the 776 + // filter rendered before we attach. 777 + await tui.waitForAbsent(b, 5000); 778 + 779 + // The filter line should show the typed value. 780 + const beforeAttach = tui.screenshot(); 781 + expect(beforeAttach.text).toContain(`Filter: ${filter}`); 782 + 783 + // Attach to `a` (which is still selected since the filter matched it). 784 + tui.press("return"); 785 + await tui.waitForText("A_MARK", 10000); 786 + 787 + // Detach back to the overview. 788 + tui.sendKeys("\x1c"); 789 + 790 + // After returning, the filter must still be present and `b` / `c` 791 + // must still be hidden by it. This is the regression — previously 792 + // the overview cleared the filter on detach. 793 + const after = await tui.waitForText(a, 10000); 794 + expect(after.text).toContain(`Filter: ${filter}`); 795 + expect(after.text).toContain(a); 796 + expect(after.text).not.toContain(b); 797 + expect(after.text).not.toContain(c); 798 + 799 + // The user can still edit the filter (cursor preserved). One more 800 + // keystroke continues from the current cursor position; ctrl+u 801 + // should clear back to nothing. 802 + tui.press("ctrl+u"); 803 + await tui.waitForText(b, 5000); 804 + const cleared = tui.screenshot(); 805 + expect(cleared.text).toContain(b); 806 + expect(cleared.text).toContain(c); 807 + }, 808 + 30000, 809 + ); 810 + 811 + it( 749 812 "session list reloads after returning from attach", 750 813 async () => { 751 814 const sessionDir = makeSessionDir();