This repository has no description
0

Configure Feed

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

Add host/session filter syntax, hide create during filter, and unit tests

Nathan Herald (Apr 15, 2026, 11:15 AM +0200) fb4e6d55 b4926719

+274 -18
+3
CHANGELOG.md
··· 9 9 - Preserves existing tags and other metadata 10 10 - Emits `session_exec` event with previous and new command 11 11 - Interactive TUI shows "Create new session..." for spawn-enabled remote hosts 12 + - Interactive filter hides "Create new session..." items when filter doesn't match "new" 13 + - `host/session` filter syntax: type `prod/api` to filter by host then session 14 + - Extracted `buildFilteredGroups` as a pure function for unit testing 12 15 13 16 ## 0.8.0 14 17
+1 -1
README.md
··· 40 40 ↑↓ select ⏎ attach q quit 41 41 ``` 42 42 43 - Arrow keys to navigate, type to filter, Enter to attach, `q` to quit. Creating a new session walks through a directory picker and name/command prompt. 43 + Arrow keys to navigate, type to filter, Enter to attach, `q` to quit. When pty-relay is installed, remote sessions appear grouped by host. Use `host/session` syntax to filter by host (e.g., `prod/api`). Creating a new session walks through a directory picker and name/command prompt. 44 44 45 45 When you detach from a session entered via the interactive list (`Ctrl+\`), you return to the list. The session keeps running in the background. 46 46
+41 -16
src/tui/interactive.ts
··· 96 96 // Relay integration 97 97 // ============================================================ 98 98 99 - interface RemoteSession { 99 + export interface RemoteSession { 100 100 name: string; 101 101 status: string; 102 102 command?: string; 103 103 cwd?: string; 104 104 } 105 105 106 - interface RelayHost { 106 + export interface RelayHost { 107 107 label: string; 108 108 url: string; 109 109 sessions: RemoteSession[]; ··· 141 141 // Computed values 142 142 // ============================================================ 143 143 144 - interface ListItem { 144 + export interface ListItem { 145 145 type: "session" | "create" | "remote" | "remote-create"; 146 146 session?: SessionInfo; 147 147 remote?: { host: RelayHost; session: RemoteSession }; ··· 184 184 return matches.map(m => m.item); 185 185 } 186 186 187 - const filteredGroups = computed<SelectableGroup<ListItem>[]>(() => { 188 - const filter = filterText.get(); 187 + /** Build filtered groups from local sessions and relay hosts. 188 + * Exported for unit testing. */ 189 + export function buildFilteredGroups( 190 + filter: string, 191 + localSessions: SessionInfo[], 192 + hosts: RelayHost[], 193 + ): SelectableGroup<ListItem>[] { 194 + // Parse "host/session" filter syntax 195 + let hostFilter = ""; 196 + let sessionFilter = filter; 197 + if (filter.includes("/")) { 198 + const slashIdx = filter.indexOf("/"); 199 + hostFilter = filter.slice(0, slashIdx).trim(); 200 + sessionFilter = filter.slice(slashIdx + 1).trim(); 201 + } 189 202 190 - // Local group 191 - const localItems: ListItem[] = sortedSessions.get().map(s => ({ type: "session" as const, session: s })); 192 - const filteredLocal = filter ? filterAndSort(filter, localItems) : localItems; 193 - // Always add "Create new session" at the end of local group 194 - const localWithCreate: ListItem[] = [...filteredLocal, { type: "create" }]; 203 + const showCreate = !filter || "new".startsWith(filter.toLowerCase()); 195 204 196 - const groups: SelectableGroup<ListItem>[] = [ 197 - { title: "Local", items: localWithCreate }, 198 - ]; 205 + // Local group — skip if host filter is set (user is filtering by remote host) 206 + const groups: SelectableGroup<ListItem>[] = []; 207 + if (!hostFilter || fuzzyMatch(hostFilter, "local").match) { 208 + const localItems: ListItem[] = localSessions.map(s => ({ type: "session" as const, session: s })); 209 + const filteredLocal = sessionFilter ? filterAndSort(sessionFilter, localItems) : localItems; 210 + const localWithCreate: ListItem[] = showCreate ? [...filteredLocal, { type: "create" }] : filteredLocal; 211 + groups.push({ title: "Local", items: localWithCreate }); 212 + } 199 213 200 214 // Remote groups from relay 201 - for (const host of relayHosts.get()) { 215 + for (const host of hosts) { 202 216 if (host.error) continue; 217 + // If host filter is set, only include matching hosts 218 + if (hostFilter && !fuzzyMatch(hostFilter, host.label).match) continue; 219 + 203 220 const remoteItems: ListItem[] = host.sessions.map(s => ({ 204 221 type: "remote" as const, 205 222 remote: { host, session: s }, 206 223 })); 207 - const filtered = filter ? filterAndSort(filter, remoteItems) : remoteItems; 224 + const filtered = sessionFilter ? filterAndSort(sessionFilter, remoteItems) : remoteItems; 208 225 const items: ListItem[] = [...filtered]; 209 - if (host.spawn_enabled) { 226 + if (host.spawn_enabled && showCreate) { 210 227 items.push({ type: "remote-create", remoteHost: host }); 211 228 } 212 229 if (items.length > 0 || !filter) { ··· 215 232 } 216 233 217 234 return groups; 235 + } 236 + 237 + const filteredGroups = computed<SelectableGroup<ListItem>[]>(() => { 238 + return buildFilteredGroups( 239 + filterText.get(), 240 + sortedSessions.get(), 241 + relayHosts.get(), 242 + ); 218 243 }); 219 244 220 245 // Total item count across all groups (for scroll region)
+227
tests/filter.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { buildFilteredGroups, type ListItem, type RelayHost } from "../src/tui/interactive.ts"; 3 + import type { SessionInfo } from "../src/sessions.ts"; 4 + 5 + function makeSession(name: string, status: "running" | "exited" = "running", opts?: { command?: string; cwd?: string; tags?: Record<string, string> }): SessionInfo { 6 + return { 7 + name, 8 + socketPath: `/tmp/${name}.sock`, 9 + pid: status === "running" ? 12345 : null, 10 + status, 11 + metadata: { 12 + command: opts?.command ?? "cat", 13 + args: [], 14 + displayCommand: opts?.command ?? "cat", 15 + cwd: opts?.cwd ?? "/tmp", 16 + createdAt: new Date().toISOString(), 17 + ...(opts?.tags ? { tags: opts.tags } : {}), 18 + }, 19 + }; 20 + } 21 + 22 + function makeHost(label: string, sessions: { name: string; command?: string; cwd?: string }[], spawn_enabled = true): RelayHost { 23 + return { 24 + label, 25 + url: `https://${label}#token`, 26 + sessions: sessions.map(s => ({ 27 + name: s.name, 28 + status: "running", 29 + command: s.command ?? "bash", 30 + cwd: s.cwd ?? "/home/user", 31 + })), 32 + spawn_enabled, 33 + error: null, 34 + }; 35 + } 36 + 37 + function itemNames(groups: ReturnType<typeof buildFilteredGroups>): string[][] { 38 + return groups.map(g => g.items.map(i => { 39 + if (i.type === "create") return "[create]"; 40 + if (i.type === "remote-create") return "[remote-create]"; 41 + if (i.type === "remote") return i.remote!.session.name; 42 + return i.session!.name; 43 + })); 44 + } 45 + 46 + function groupTitles(groups: ReturnType<typeof buildFilteredGroups>): string[] { 47 + return groups.map(g => g.title); 48 + } 49 + 50 + describe("buildFilteredGroups", () => { 51 + describe("no filter", () => { 52 + it("shows all local sessions with create item", () => { 53 + const sessions = [makeSession("web"), makeSession("worker")]; 54 + const groups = buildFilteredGroups("", sessions, []); 55 + 56 + expect(groupTitles(groups)).toEqual(["Local"]); 57 + expect(itemNames(groups)).toEqual([["web", "worker", "[create]"]]); 58 + }); 59 + 60 + it("shows remote groups with create when spawn enabled", () => { 61 + const sessions = [makeSession("local-1")]; 62 + const hosts = [makeHost("server-a", [{ name: "remote-1" }])]; 63 + const groups = buildFilteredGroups("", sessions, hosts); 64 + 65 + expect(groupTitles(groups)).toEqual(["Local", "server-a"]); 66 + expect(itemNames(groups)[1]).toEqual(["remote-1", "[remote-create]"]); 67 + }); 68 + 69 + it("hides remote create when spawn not enabled", () => { 70 + const sessions: SessionInfo[] = []; 71 + const hosts = [makeHost("server-a", [{ name: "r1" }], false)]; 72 + const groups = buildFilteredGroups("", sessions, hosts); 73 + 74 + expect(itemNames(groups)[1]).toEqual(["r1"]); 75 + }); 76 + }); 77 + 78 + describe("session filter", () => { 79 + it("filters local sessions by name", () => { 80 + const sessions = [makeSession("web-server"), makeSession("worker"), makeSession("db")]; 81 + const groups = buildFilteredGroups("web", sessions, []); 82 + 83 + const local = groups.find(g => g.title === "Local")!; 84 + expect(local.items.some(i => i.session?.name === "web-server")).toBe(true); 85 + expect(local.items.some(i => i.session?.name === "worker")).toBe(false); 86 + }); 87 + 88 + it("filters remote sessions by name", () => { 89 + const hosts = [makeHost("server-a", [ 90 + { name: "api" }, 91 + { name: "web" }, 92 + { name: "cron" }, 93 + ])]; 94 + const groups = buildFilteredGroups("api", [], hosts); 95 + 96 + const remote = groups.find(g => g.title === "server-a"); 97 + expect(remote).toBeDefined(); 98 + const names = remote!.items.filter(i => i.type === "remote").map(i => i.remote!.session.name); 99 + expect(names).toContain("api"); 100 + expect(names).not.toContain("cron"); 101 + }); 102 + 103 + it("hides create items when filter does not match 'new'", () => { 104 + const sessions = [makeSession("web")]; 105 + const groups = buildFilteredGroups("web", sessions, []); 106 + 107 + const local = groups.find(g => g.title === "Local")!; 108 + expect(local.items.some(i => i.type === "create")).toBe(false); 109 + }); 110 + 111 + it("shows create items when filter is prefix of 'new'", () => { 112 + const sessions = [makeSession("newsletter")]; 113 + 114 + for (const prefix of ["n", "ne", "new"]) { 115 + const groups = buildFilteredGroups(prefix, sessions, []); 116 + const local = groups.find(g => g.title === "Local")!; 117 + expect(local.items.some(i => i.type === "create")).toBe(true); 118 + } 119 + }); 120 + 121 + it("hides create items when filter goes past 'new'", () => { 122 + const sessions = [makeSession("newsletter")]; 123 + const groups = buildFilteredGroups("news", sessions, []); 124 + 125 + const local = groups.find(g => g.title === "Local")!; 126 + expect(local.items.some(i => i.type === "create")).toBe(false); 127 + }); 128 + }); 129 + 130 + describe("host/session filter syntax", () => { 131 + it("filters by host name before slash", () => { 132 + const sessions = [makeSession("local-web")]; 133 + const hosts = [ 134 + makeHost("prod-server", [{ name: "api" }, { name: "web" }]), 135 + makeHost("staging", [{ name: "api" }, { name: "web" }]), 136 + ]; 137 + const groups = buildFilteredGroups("prod/", sessions, hosts); 138 + 139 + expect(groupTitles(groups)).toContain("prod-server"); 140 + expect(groupTitles(groups)).not.toContain("staging"); 141 + }); 142 + 143 + it("filters by session name after slash", () => { 144 + const hosts = [makeHost("server", [ 145 + { name: "api" }, 146 + { name: "web" }, 147 + { name: "cron" }, 148 + ])]; 149 + const groups = buildFilteredGroups("server/api", [], hosts); 150 + 151 + const remote = groups.find(g => g.title === "server")!; 152 + const names = remote.items.filter(i => i.type === "remote").map(i => i.remote!.session.name); 153 + expect(names).toContain("api"); 154 + expect(names).not.toContain("web"); 155 + }); 156 + 157 + it("host filter hides local when it does not match 'local'", () => { 158 + const sessions = [makeSession("web")]; 159 + const hosts = [makeHost("prod", [{ name: "api" }])]; 160 + const groups = buildFilteredGroups("prod/", sessions, hosts); 161 + 162 + expect(groupTitles(groups)).not.toContain("Local"); 163 + }); 164 + 165 + it("host filter shows local when it matches 'local'", () => { 166 + const sessions = [makeSession("web")]; 167 + const hosts = [makeHost("prod", [{ name: "api" }])]; 168 + const groups = buildFilteredGroups("local/", sessions, hosts); 169 + 170 + expect(groupTitles(groups)).toContain("Local"); 171 + expect(groupTitles(groups)).not.toContain("prod"); 172 + }); 173 + 174 + it("both host and session filter combined", () => { 175 + const hosts = [ 176 + makeHost("prod", [{ name: "api" }, { name: "web" }]), 177 + makeHost("staging", [{ name: "api" }, { name: "web" }]), 178 + ]; 179 + const groups = buildFilteredGroups("prod/web", [], hosts); 180 + 181 + expect(groupTitles(groups)).toEqual(["prod"]); 182 + const names = groups[0].items.filter(i => i.type === "remote").map(i => i.remote!.session.name); 183 + expect(names).toEqual(["web"]); 184 + }); 185 + }); 186 + 187 + describe("edge cases", () => { 188 + it("empty sessions and no relay", () => { 189 + const groups = buildFilteredGroups("", [], []); 190 + expect(groupTitles(groups)).toEqual(["Local"]); 191 + expect(itemNames(groups)).toEqual([["[create]"]]); 192 + }); 193 + 194 + it("relay host with error is skipped", () => { 195 + const hosts: RelayHost[] = [{ 196 + label: "broken", 197 + url: "https://broken#token", 198 + sessions: [], 199 + spawn_enabled: true, 200 + error: "connection refused", 201 + }]; 202 + const groups = buildFilteredGroups("", [], hosts); 203 + expect(groupTitles(groups)).toEqual(["Local"]); 204 + }); 205 + 206 + it("filter matches no sessions", () => { 207 + const sessions = [makeSession("web")]; 208 + const groups = buildFilteredGroups("zzzzz", sessions, []); 209 + 210 + const local = groups.find(g => g.title === "Local")!; 211 + expect(local.items).toEqual([]); 212 + }); 213 + 214 + it("running sessions rank above exited in filter results", () => { 215 + const sessions = [ 216 + makeSession("api-server", "exited"), 217 + makeSession("api-worker", "running"), 218 + ]; 219 + const groups = buildFilteredGroups("api", sessions, []); 220 + 221 + const local = groups.find(g => g.title === "Local")!; 222 + const sessionItems = local.items.filter(i => i.type === "session"); 223 + expect(sessionItems[0].session!.name).toBe("api-worker"); 224 + expect(sessionItems[1].session!.name).toBe("api-server"); 225 + }); 226 + }); 227 + });
+2 -1
tests/tui.test.ts
··· 329 329 330 330 let ss = tui.screenshot(); 331 331 expect(ss.text).toContain(name1); 332 - expect(ss.text).toContain("Create new session..."); 332 + // "Create new session..." should be hidden when filter doesn't match "new" 333 + expect(ss.text).not.toContain("Create new session..."); 333 334 334 335 // Backspace to remove filter 335 336 for (let i = 0; i < filterText.length; i++) {