This repository has no description
0

Configure Feed

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

Add --preselect-new and --filter-tag, show tags in list, fix pty up tag removal

Nathan Herald (Apr 15, 2026, 5:46 PM +0200) 61793b63 cfe57668

+519 -42
+17
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Interactive TUI 6 + - Add `--preselect-new` flag: `pty --preselect-new` opens the interactive TUI with "Create new session..." pre-selected (useful for pty-layout panes that should land on the create prompt) 7 + - Add `--filter-tag key=value` flag (repeatable): filters the TUI to sessions matching all given tags AND auto-applies those tags to any session created from this TUI instance — so new sessions stay in the filtered view (e.g., pty-layout layouts) 8 + - Tag filter is shown in the Filter line; remote groups are hidden when a tag filter is active (remote session tags aren't surfaced by pty-relay) 9 + 10 + ### Listing 11 + - `pty list` now shows tags by default (hashtag format, e.g., `#role=web`) — internal bookkeeping keys (`ptyfile*`, `strategy`, `supervisor.status`) are hidden 12 + - `pty list --tags` now means "show all tags including internal bookkeeping" (previously required to show any tags) 13 + - Add `pty list --filter-tag key=value` (repeatable): show only sessions matching all given tags 14 + 15 + ### Client API 16 + - Export `extractFilterTags` and `matchesAllTags` from `@myobie/pty/client` so third-party tools (e.g., pty-relay) can accept and apply the same `--filter-tag key=value` syntax 17 + - Add optional `tags?: Record<string, string>` on remote session entries so pty-relay can surface tags in `ls --json` and have the interactive TUI filter remote sessions by them 18 + 19 + ### Project files 20 + - `pty up` now removes tags that were removed from `pty.toml` — toml-managed tag keys are tracked in a `ptyfile.tags` meta tag so manually-added tags (set via `pty tag`) are preserved 21 + 5 22 ### Fixes 6 23 - Fix garbage characters in `less`/`git log`: respond to terminal queries (OSC 10/11/4, DA2, DSR, XTVERSION) and strip them from client broadcast so the client's terminal doesn't respond with duplicate input 7 24
+6 -2
README.md
··· 48 48 49 49 ```sh 50 50 pty # interactive session manager 51 + pty --preselect-new # open the TUI with "Create new session..." selected 52 + pty --filter-tag layout=work # TUI filtered by tag; new sessions inherit the tag 51 53 pty run -- node server.js # start a session (auto-named) 52 54 pty run --name myserver -- node server.js # start with an explicit name 53 55 pty run -d -- node server.js # start in the background ··· 56 58 pty run --tag owner=forge -- node srv.js # tag a session with metadata 57 59 pty run --cwd /path -- node server.js # run in a specific directory 58 60 59 - pty list # show active sessions 60 - pty list --tags # show sessions with tags (#key=value) 61 + pty list # show active sessions (tags shown by default) 62 + pty list --tags # include internal bookkeeping tags (ptyfile*, strategy, etc.) 61 63 pty list --json # show as JSON 62 64 pty list --remote # include remote sessions via pty-relay 65 + pty list --filter-tag role=web # show only sessions with matching tag (repeatable) 63 66 64 67 pty attach myserver # reconnect to a session 65 68 pty attach -r myserver # reconnect, auto-restart if exited ··· 209 212 spawnDaemon, listSessions, getSession, 210 213 SessionConnection, sendData, peekScreen, queryStats, 211 214 EventFollower, readRecentEvents, 215 + extractFilterTags, matchesAllTags, 212 216 } from "@myobie/pty/client"; 213 217 import { PtyServer } from "@myobie/pty/server"; // native addon (node-pty) 214 218 import { resolveKey } from "@myobie/pty/keys"; // browser-safe
+106 -34
src/cli.ts
··· 24 24 import { EventFollower, EventWriter, EventType, readRecentEvents, formatEvent } from "./events.ts"; 25 25 import { readPtyFile, type PtySessionDef } from "./ptyfile.ts"; 26 26 import { getSupervisorDir } from "./supervisor.ts"; 27 + import { extractFilterTags as extractFilterTagsImpl, matchesAllTags } from "./tags.ts"; 27 28 28 29 // Lazy-load the interactive TUI so non-interactive commands don't crash when 29 30 // the caller's cwd was deleted (the TUI module evaluates process.cwd() at load). 30 - async function runInteractive(): Promise<void> { 31 + async function runInteractive(options?: { preselectNew?: boolean; filterTags?: Record<string, string> }): Promise<void> { 31 32 const mod = await import("./tui/interactive.ts"); 32 - await mod.runInteractive(); 33 + await mod.runInteractive(options); 34 + } 35 + 36 + /** CLI wrapper around `extractFilterTags` that exits on invalid input. */ 37 + function extractFilterTags(args: string[]): Record<string, string> { 38 + try { 39 + return extractFilterTagsImpl(args); 40 + } catch (e: any) { 41 + console.error(e.message); 42 + process.exit(1); 43 + } 33 44 } 34 45 35 46 function usage(): void { 36 47 console.log(`Usage: 37 48 pty Interactive session manager 49 + pty --preselect-new Open the TUI with "Create new session..." pre-selected 50 + pty --filter-tag key=value Filter TUI to sessions with a tag; auto-applied to new sessions 38 51 pty run -- <command> [args...] Create a session and attach (auto-named) 39 52 pty run --name <n> -- <command> [args...] Create a named session and attach 40 53 pty run -d -- <command> [args...] Create in the background ··· 59 72 pty events --all Follow events from all sessions 60 73 pty events --recent <name> Show recent events and exit 61 74 pty events --json <name> Output raw JSONL 62 - pty list List active sessions 63 - pty list --tags List sessions with tags (#key=value) 75 + pty list List active sessions (with tags) 76 + pty list --tags Show all tags including internal bookkeeping (ptyfile*, strategy, etc.) 64 77 pty list --json List sessions as JSON 78 + pty list --filter-tag key=value List only sessions matching the tag (repeatable) 65 79 pty up Start all sessions from pty.toml 66 80 pty up <dir> Start sessions from <dir>/pty.toml 67 81 pty up <name> [<name>...] Start specific sessions from pty.toml ··· 111 125 async function main(): Promise<void> { 112 126 const args = process.argv.slice(2); 113 127 114 - if (args.length === 0) { 115 - await runInteractive(); 128 + // Interactive-mode flags (--preselect-new, --filter-tag) can appear before 129 + // the subcommand. Peek at the subcommand without consuming flags; if it's 130 + // the interactive TUI (none, "i", or "interactive"), consume those flags 131 + // here. Otherwise leave them in args for the subcommand to parse itself. 132 + // 133 + // Detect the subcommand: first positional that isn't a flag or a value for 134 + // a known flag that takes a value (currently just --filter-tag). 135 + let subcommand = ""; 136 + for (let i = 0; i < args.length; i++) { 137 + const a = args[i]; 138 + if (a === "--filter-tag") { i++; continue; } 139 + if (a.startsWith("-")) continue; 140 + subcommand = a; 141 + break; 142 + } 143 + 144 + let preselectNew = false; 145 + let interactiveFilterTags: Record<string, string> = {}; 146 + if (!subcommand || subcommand === "i" || subcommand === "interactive") { 147 + preselectNew = args.includes("--preselect-new"); 148 + interactiveFilterTags = extractFilterTags(args); 149 + } 150 + const dispatchArgs = args.filter((a) => a !== "--preselect-new"); 151 + 152 + if (dispatchArgs.length === 0) { 153 + await runInteractive({ preselectNew, filterTags: interactiveFilterTags }); 116 154 return; 117 155 } 118 156 119 - const command = args[0]; 157 + const command = dispatchArgs[0]; 120 158 121 159 switch (command) { 122 160 case "interactive": 123 161 case "i": { 124 - await runInteractive(); 162 + await runInteractive({ preselectNew, filterTags: interactiveFilterTags }); 125 163 break; 126 164 } 127 165 ··· 401 439 402 440 case "list": 403 441 case "ls": { 404 - const jsonFlag = args.includes("--json"); 405 - const tagsFlag = args.includes("--tags"); 406 - const remoteFlag = args.includes("--remote"); 407 - await cmdList(jsonFlag, tagsFlag, remoteFlag); 442 + const listArgs = args.slice(); 443 + const listFilterTags = extractFilterTags(listArgs); 444 + const jsonFlag = listArgs.includes("--json"); 445 + const tagsFlag = listArgs.includes("--tags"); 446 + const remoteFlag = listArgs.includes("--remote"); 447 + await cmdList(jsonFlag, tagsFlag, remoteFlag, listFilterTags); 408 448 break; 409 449 } 410 450 ··· 969 1009 }); 970 1010 } 971 1011 972 - async function cmdList(json = false, showTags = false, remote = false): Promise<void> { 973 - const sessions = await listSessions(); 1012 + async function cmdList(json = false, showTags = false, remote = false, filterTags: Record<string, string> = {}): Promise<void> { 1013 + let sessions = await listSessions(); 1014 + if (Object.keys(filterTags).length > 0) { 1015 + sessions = sessions.filter((s) => matchesAllTags(s.metadata?.tags, filterTags)); 1016 + } 974 1017 975 1018 // Fetch relay hosts if --remote 976 1019 let remoteHosts: { label: string; sessions: { name: string; status: string; command?: string; cwd?: string }[]; error: string | null }[] = []; ··· 1014 1057 const running = sessions.filter((s) => s.status === "running"); 1015 1058 const exited = sessions.filter((s) => s.status === "exited"); 1016 1059 1060 + // Render tags as hashtags. When `showAll` is false, hide internal bookkeeping 1061 + // keys (ptyfile*, supervisor.status) since those have dedicated markers or 1062 + // aren't meaningful to users. `--tags` (showAll=true) includes everything. 1063 + const renderTags = (tags: Record<string, string> | undefined, showAll: boolean): string => { 1064 + if (!tags) return ""; 1065 + const entries = Object.entries(tags).filter(([k]) => 1066 + showAll || (k !== "ptyfile" && k !== "ptyfile.session" && k !== "ptyfile.tags" && k !== "supervisor.status" && k !== "strategy"), 1067 + ); 1068 + return entries.length > 0 ? " " + entries.map(([k, v]) => `#${k}=${v}`).join(" ") : ""; 1069 + }; 1070 + 1017 1071 if (running.length > 0) { 1018 1072 console.log("Active sessions:"); 1019 1073 for (const session of running) { ··· 1023 1077 const cwd = session.metadata?.cwd 1024 1078 ? shortPath(session.metadata.cwd) 1025 1079 : ""; 1026 - const tagStr = showTags && session.metadata?.tags 1027 - ? " " + Object.entries(session.metadata.tags).map(([k, v]) => `#${k}=${v}`).join(" ") 1028 - : ""; 1080 + const tagStr = renderTags(session.metadata?.tags, showTags); 1029 1081 const marker = strategyMarker(session.metadata?.tags); 1030 1082 console.log(` \x1b[1;36m${session.name}\x1b[0m${marker}${tagStr} (pid: ${session.pid}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); 1031 1083 } ··· 1042 1094 const cmd = meta 1043 1095 ? meta.displayCommand 1044 1096 : ""; 1045 - const tagStr = showTags && meta?.tags 1046 - ? " " + Object.entries(meta.tags).map(([k, v]) => `#${k}=${v}`).join(" ") 1047 - : ""; 1097 + const tagStr = renderTags(meta?.tags, showTags); 1048 1098 const marker = strategyMarker(meta?.tags); 1049 1099 console.log(` \x1b[1m${session.name}\x1b[0m${marker}${tagStr} (exited with code ${code}, ${ago}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); 1050 1100 } ··· 1720 1770 let skipped = 0; 1721 1771 1722 1772 for (const sess of sessions) { 1773 + const tomlPath = path.join(ptyFile.dir, "pty.toml"); 1774 + const userTomlKeys = Object.keys(sess.tags ?? {}).sort(); 1775 + const ptyfileTagsValue = userTomlKeys.join(","); 1776 + const tomlTags: Record<string, string> = { 1777 + ...sess.tags, 1778 + ptyfile: tomlPath, 1779 + "ptyfile.session": sess.shortName, 1780 + "ptyfile.tags": ptyfileTagsValue, 1781 + }; 1782 + 1723 1783 if (runningNames.has(sess.name)) { 1724 - // Sync tags from toml to the running session (including ptyfile metadata) 1784 + // Sync tags from toml to the running session (including ptyfile metadata). 1785 + // Track which tag keys came from the toml via "ptyfile.tags" so that 1786 + // removing a tag from the toml causes it to be removed here (but 1787 + // manually-added tags — those not in "ptyfile.tags" — are preserved). 1725 1788 const currentMeta = readMetadata(sess.name); 1726 - const tomlPath = path.join(ptyFile.dir, "pty.toml"); 1727 - const tomlTags = { ...sess.tags, ptyfile: tomlPath, "ptyfile.session": sess.shortName }; 1728 1789 const currentTags = currentMeta?.tags ?? {}; 1790 + 1729 1791 const updates: Record<string, string> = {}; 1730 1792 for (const [k, v] of Object.entries(tomlTags)) { 1731 1793 if (currentTags[k] !== v) updates[k] = v; 1732 1794 } 1733 - if (Object.keys(updates).length > 0) { 1795 + 1796 + const prevTomlKeys = (currentTags["ptyfile.tags"] ?? "") 1797 + .split(",") 1798 + .map((k) => k.trim()) 1799 + .filter((k) => k.length > 0); 1800 + const newKeySet = new Set(userTomlKeys); 1801 + const removals = prevTomlKeys.filter((k) => !newKeySet.has(k)); 1802 + 1803 + if (Object.keys(updates).length > 0 || removals.length > 0) { 1734 1804 try { 1735 - updateTags(sess.name, updates); 1736 - const changed = Object.entries(updates).map(([k, v]) => `${k}=${v}`).join(", "); 1737 - console.log(` ● ${sess.name} (already running, updated tags: ${changed})`); 1805 + updateTags(sess.name, updates, removals); 1806 + const changedTagUpdates = Object.entries(updates) 1807 + .filter(([k]) => k !== "ptyfile" && k !== "ptyfile.session" && k !== "ptyfile.tags") 1808 + .map(([k, v]) => `${k}=${v}`); 1809 + const changedRemovals = removals.map((k) => `-${k}`); 1810 + const changed = [...changedTagUpdates, ...changedRemovals].join(", "); 1811 + if (changed) { 1812 + console.log(` ● ${sess.name} (already running, updated tags: ${changed})`); 1813 + } else { 1814 + console.log(` ● ${sess.name} (already running)`); 1815 + } 1738 1816 } catch { 1739 1817 console.log(` ● ${sess.name} (already running)`); 1740 1818 } ··· 1752 1830 } 1753 1831 1754 1832 try { 1755 - const tomlPath = path.join(ptyFile.dir, "pty.toml"); 1756 - const tags = { 1757 - ...sess.tags, 1758 - ptyfile: tomlPath, 1759 - "ptyfile.session": sess.shortName, 1760 - }; 1761 1833 await spawnDaemon({ 1762 1834 name: sess.name, 1763 1835 command: "/bin/sh", 1764 1836 args: ["-c", sess.command], 1765 1837 displayCommand: sess.command, 1766 1838 cwd: ptyFile.dir, 1767 - tags, 1839 + tags: tomlTags, 1768 1840 }); 1769 1841 console.log(` ● ${sess.name} (started)`); 1770 1842 started++;
+3
src/client-api.ts
··· 42 42 // Project files 43 43 export { readPtyFile, type PtyFile, type PtySessionDef } from "./ptyfile.ts"; 44 44 45 + // Tag filter helpers (used by --filter-tag; shared with pty-relay) 46 + export { extractFilterTags, matchesAllTags } from "./tags.ts"; 47 + 45 48 // Keys 46 49 export { resolveKey, parseSeqValue } from "./keys.ts"; 47 50
+46
src/tags.ts
··· 1 + /** 2 + * Shared helpers for working with session tag filters. 3 + * 4 + * These are used by the pty CLI (`pty list --filter-tag`, `pty --filter-tag`) 5 + * and are exposed on `@myobie/pty/client` so tools like pty-relay can accept 6 + * and apply the same filter syntax. 7 + */ 8 + 9 + /** 10 + * Extract all `--filter-tag key=value` pairs from `args` (repeatable). 11 + * 12 + * Mutates `args`: consumed `--filter-tag` and its value are removed in place. 13 + * Returns the collected tags as an object. 14 + * 15 + * Throws if `--filter-tag` appears without a following `key=value` token. 16 + */ 17 + export function extractFilterTags(args: string[]): Record<string, string> { 18 + const tags: Record<string, string> = {}; 19 + for (let i = 0; i < args.length; i++) { 20 + if (args[i] !== "--filter-tag") continue; 21 + const kv = args[i + 1]; 22 + if (!kv || !kv.includes("=")) { 23 + throw new Error(`--filter-tag expects "key=value"`); 24 + } 25 + const eq = kv.indexOf("="); 26 + tags[kv.slice(0, eq)] = kv.slice(eq + 1); 27 + args.splice(i, 2); 28 + i--; 29 + } 30 + return tags; 31 + } 32 + 33 + /** 34 + * Returns `true` if `sessionTags` contains every key/value pair in `filterTags` 35 + * (AND match). An empty `filterTags` always matches. A session with no tags 36 + * only matches an empty `filterTags`. 37 + */ 38 + export function matchesAllTags( 39 + sessionTags: Record<string, string> | undefined, 40 + filterTags: Record<string, string>, 41 + ): boolean { 42 + for (const [k, v] of Object.entries(filterTags)) { 43 + if (sessionTags?.[k] !== v) return false; 44 + } 45 + return true; 46 + }
+63 -6
src/tui/interactive.ts
··· 8 8 cleanupAll, getSession, getSessionDir, type SessionInfo, 9 9 } from "../sessions.ts"; 10 10 import { spawnDaemon, resolveCommand } from "../spawn.ts"; 11 + import { matchesAllTags } from "../tags.ts"; 11 12 import { 12 13 app, screen, signal, computed, batch, 13 14 text, row, spacer, panel, selectable, footer, canvas, ··· 44 45 const filterText = signal(""); 45 46 const selectedIndex = signal(0); 46 47 const currentScreen = signal<"list" | "create" | "remote-create">("list"); 48 + 49 + /** Tag filter from `--filter-tag key=value`. Filters the list AND auto-applies 50 + * to any session created from this TUI instance. */ 51 + const filterTags = signal<Record<string, string>>({}); 47 52 48 53 // Theme — persisted to ~/.local/state/pty/theme 49 54 const themeNames = Object.keys(themes); ··· 101 106 status: string; 102 107 command?: string; 103 108 cwd?: string; 109 + /** Optional tags reported by the relay (e.g., pty-relay ls --json). Used 110 + * by the interactive TUI's --filter-tag to filter remote sessions. */ 111 + tags?: Record<string, string>; 104 112 } 105 113 106 114 export interface RelayHost { ··· 148 156 remoteHost?: RelayHost; 149 157 } 150 158 151 - const sortedSessions = computed<SessionInfo[]>(() => sortSessions(sessions.get())); 159 + const sortedSessions = computed<SessionInfo[]>(() => { 160 + const all = sortSessions(sessions.get()); 161 + const required = filterTags.get(); 162 + if (Object.keys(required).length === 0) return all; 163 + return all.filter((s) => matchesAllTags(s.metadata?.tags, required)); 164 + }); 152 165 153 166 function filterAndSort(filter: string, items: ListItem[]): ListItem[] { 154 167 if (!filter) return items; ··· 235 248 } 236 249 237 250 const filteredGroups = computed<SelectableGroup<ListItem>[]>(() => { 251 + // When --filter-tag is active, filter remote sessions by tag too. Hosts 252 + // whose relay reports no tags will simply render no matching sessions. 253 + const required = filterTags.get(); 254 + const requireTagMatch = Object.keys(required).length > 0; 255 + const hosts = requireTagMatch 256 + ? relayHosts.get().map((h) => ({ 257 + ...h, 258 + sessions: h.sessions.filter((s) => matchesAllTags(s.tags, required)), 259 + })) 260 + : relayHosts.get(); 238 261 return buildFilteredGroups( 239 262 filterText.get(), 240 263 sortedSessions.get(), 241 - relayHosts.get(), 264 + hosts, 242 265 ); 243 266 }); 244 267 ··· 321 344 ); 322 345 323 346 const filter = filterText.get(); 347 + const tagFilter = filterTags.get(); 348 + const tagFilterStr = Object.entries(tagFilter).map(([k, v]) => `#${k}=${v}`).join(" "); 324 349 const filterLine = filter 325 - ? text(" Filter: " + filter, "primary") 326 - : text(" Filter: (type to filter)", "muted", { dim: true }); 350 + ? text(" Filter: " + filter + (tagFilterStr ? " " + tagFilterStr : ""), "primary") 351 + : tagFilterStr 352 + ? text(" Filter: " + tagFilterStr, "primary") 353 + : text(" Filter: (type to filter)", "muted", { dim: true }); 327 354 328 355 const hasRelay = relayHosts.get().length > 0; 329 356 ··· 824 851 const shellCmd = "/bin/sh"; 825 852 const shellArgs = ["-c", command]; 826 853 854 + // Auto-apply --filter-tag tags so the new session stays in the layout. 855 + const tags = filterTags.peek(); 856 + const spawnOpts = { name, command: shellCmd, args: shellArgs, displayCommand: command, cwd: dir, ...(Object.keys(tags).length > 0 ? { tags } : {}) }; 857 + 827 858 try { 828 - await spawnDaemon({ name, command: shellCmd, args: shellArgs, displayCommand: command, cwd: dir }); 859 + await spawnDaemon(spawnOpts); 829 860 } catch (e: any) { 830 861 releaseLock(name); 831 862 console.error(e.message); ··· 899 930 // Entry point 900 931 // ============================================================ 901 932 902 - export async function runInteractive(): Promise<void> { 933 + export interface RunInteractiveOptions { 934 + /** Pre-select the local "+ Create new session..." item on startup. */ 935 + preselectNew?: boolean; 936 + /** Filter the local list to sessions with all matching tags, and apply 937 + * them to any session created from this TUI. */ 938 + filterTags?: Record<string, string>; 939 + } 940 + 941 + export async function runInteractive(options?: RunInteractiveOptions): Promise<void> { 942 + if (options?.filterTags && Object.keys(options.filterTags).length > 0) { 943 + filterTags.set(options.filterTags); 944 + } 945 + 903 946 const sessionList = await listSessions(); 904 947 sessions.set(sessionList); 948 + 949 + if (options?.preselectNew) { 950 + const groups = filteredGroups.get(); 951 + let idx = 0; 952 + outer: for (const g of groups) { 953 + for (const item of g.items) { 954 + if (item.type === "create") { 955 + selectedIndex.set(idx); 956 + break outer; 957 + } 958 + idx++; 959 + } 960 + } 961 + } 905 962 906 963 // Fetch relay hosts in the background (non-blocking) 907 964 refreshRelayHosts();
+60
tests/tags-helpers.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { extractFilterTags, matchesAllTags } from "../src/tags.ts"; 3 + 4 + describe("extractFilterTags", () => { 5 + it("returns empty object when no --filter-tag flags present", () => { 6 + const args = ["list", "--json"]; 7 + expect(extractFilterTags(args)).toEqual({}); 8 + expect(args).toEqual(["list", "--json"]); 9 + }); 10 + 11 + it("extracts a single --filter-tag and removes it from args", () => { 12 + const args = ["list", "--filter-tag", "role=web", "--json"]; 13 + expect(extractFilterTags(args)).toEqual({ role: "web" }); 14 + expect(args).toEqual(["list", "--json"]); 15 + }); 16 + 17 + it("extracts multiple --filter-tag entries", () => { 18 + const args = ["--filter-tag", "role=web", "--filter-tag", "env=prod"]; 19 + expect(extractFilterTags(args)).toEqual({ role: "web", env: "prod" }); 20 + expect(args).toEqual([]); 21 + }); 22 + 23 + it("preserves = signs in values", () => { 24 + const args = ["--filter-tag", "note=key=value"]; 25 + expect(extractFilterTags(args)).toEqual({ note: "key=value" }); 26 + }); 27 + 28 + it("throws when --filter-tag has no value", () => { 29 + expect(() => extractFilterTags(["--filter-tag"])).toThrow(/key=value/); 30 + }); 31 + 32 + it("throws when --filter-tag value has no = sign", () => { 33 + expect(() => extractFilterTags(["--filter-tag", "nope"])).toThrow(/key=value/); 34 + }); 35 + }); 36 + 37 + describe("matchesAllTags", () => { 38 + it("empty filter matches any session (including undefined)", () => { 39 + expect(matchesAllTags(undefined, {})).toBe(true); 40 + expect(matchesAllTags({}, {})).toBe(true); 41 + expect(matchesAllTags({ role: "web" }, {})).toBe(true); 42 + }); 43 + 44 + it("session with no tags fails non-empty filter", () => { 45 + expect(matchesAllTags(undefined, { role: "web" })).toBe(false); 46 + expect(matchesAllTags({}, { role: "web" })).toBe(false); 47 + }); 48 + 49 + it("all filter tags must match (AND)", () => { 50 + expect(matchesAllTags({ role: "web", env: "prod" }, { role: "web" })).toBe(true); 51 + expect(matchesAllTags({ role: "web", env: "prod" }, { role: "web", env: "prod" })).toBe(true); 52 + expect(matchesAllTags({ role: "web", env: "prod" }, { role: "web", env: "dev" })).toBe(false); 53 + expect(matchesAllTags({ role: "web" }, { role: "web", env: "prod" })).toBe(false); 54 + }); 55 + 56 + it("values must match exactly", () => { 57 + expect(matchesAllTags({ role: "web" }, { role: "Web" })).toBe(false); 58 + expect(matchesAllTags({ role: "web" }, { role: "" })).toBe(false); 59 + }); 60 + });
+36
tests/tags.test.ts
··· 128 128 expect(session.tags).toEqual({ owner: "myapp" }); 129 129 }, 15000); 130 130 131 + it("pty list --filter-tag filters JSON output to matching sessions", async () => { 132 + const dir = makeSessionDir(); 133 + const matchName = uniqueName(); 134 + const otherName = uniqueName(); 135 + await startDaemon(dir, matchName, "cat", [], { layout: "work", role: "srv" }); 136 + await startDaemon(dir, otherName, "cat", [], { layout: "play" }); 137 + 138 + const output = runCli(dir, "list", "--json", "--filter-tag", "layout=work"); 139 + const sessions = JSON.parse(output); 140 + expect(sessions.map((s: any) => s.name)).toEqual([matchName]); 141 + }, 15000); 142 + 143 + it("pty list --filter-tag requires all tags to match (AND)", async () => { 144 + const dir = makeSessionDir(); 145 + const bothName = uniqueName(); 146 + const oneName = uniqueName(); 147 + await startDaemon(dir, bothName, "cat", [], { layout: "work", role: "srv" }); 148 + await startDaemon(dir, oneName, "cat", [], { layout: "work" }); 149 + 150 + const output = runCli(dir, "list", "--json", "--filter-tag", "layout=work", "--filter-tag", "role=srv"); 151 + const sessions = JSON.parse(output); 152 + expect(sessions.map((s: any) => s.name)).toEqual([bothName]); 153 + }, 15000); 154 + 155 + it("pty list --filter-tag filters text output too", async () => { 156 + const dir = makeSessionDir(); 157 + const matchName = uniqueName(); 158 + const otherName = uniqueName(); 159 + await startDaemon(dir, matchName, "cat", [], { layout: "work" }); 160 + await startDaemon(dir, otherName, "cat", [], { layout: "play" }); 161 + 162 + const output = runCli(dir, "list", "--filter-tag", "layout=work"); 163 + expect(output).toContain(matchName); 164 + expect(output).not.toContain(otherName); 165 + }, 15000); 166 + 131 167 it("sessions without tags have no tags field in metadata", async () => { 132 168 const dir = makeSessionDir(); 133 169 const name = uniqueName();
+68
tests/tui.test.ts
··· 273 273 ); 274 274 275 275 it( 276 + "--preselect-new pre-selects the Create new session item", 277 + async () => { 278 + const sessionDir = makeSessionDir(); 279 + const name = uniqueName(); 280 + 281 + const { pid } = await createBackgroundSession( 282 + sessionDir, 283 + name, 284 + "sh", 285 + ["-c", "sleep 300"], 286 + os.tmpdir() 287 + ); 288 + bgPids.push(pid); 289 + 290 + const tui = Session.spawn(nodeBin, [cliPath, "--preselect-new"], { 291 + rows: 24, 292 + cols: 80, 293 + env: { PTY_SESSION_DIR: sessionDir, TERM: "xterm-256color" }, 294 + }); 295 + tuiSessions.push(tui); 296 + 297 + const ss = await tui.waitForText("\u25b8 + Create new session", 10000); 298 + // Selected marker (▸) should precede the create item, not the session 299 + expect(ss.text).toMatch(/\u25b8 \+ Create new session/); 300 + const sessionLine = ss.lines.find((l) => l.includes(name)); 301 + expect(sessionLine).toBeDefined(); 302 + expect(sessionLine!).not.toMatch(/\u25b8 /); 303 + }, 304 + 15000 305 + ); 306 + 307 + it( 308 + "--filter-tag hides sessions that lack the tag", 309 + async () => { 310 + const sessionDir = makeSessionDir(); 311 + const matchName = uniqueName(); 312 + const otherName = uniqueName(); 313 + 314 + const { pid: p1 } = await createBackgroundSession(sessionDir, matchName, "sh", ["-c", "sleep 300"], os.tmpdir()); 315 + bgPids.push(p1); 316 + const { pid: p2 } = await createBackgroundSession(sessionDir, otherName, "sh", ["-c", "sleep 300"], os.tmpdir()); 317 + bgPids.push(p2); 318 + 319 + // Tag one of them — use the CLI so the metadata write goes through the 320 + // same code path as a real user. 321 + spawn(nodeBin, [cliPath, "tag", matchName, "layout=work"], { 322 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 323 + stdio: "ignore", 324 + }).on("exit", () => {}); 325 + await new Promise((r) => setTimeout(r, 500)); 326 + 327 + const tui = Session.spawn(nodeBin, [cliPath, "--filter-tag", "layout=work"], { 328 + rows: 24, 329 + cols: 80, 330 + env: { PTY_SESSION_DIR: sessionDir, TERM: "xterm-256color" }, 331 + }); 332 + tuiSessions.push(tui); 333 + 334 + const ss = await tui.waitForText(matchName, 10000); 335 + expect(ss.text).toContain(matchName); 336 + expect(ss.text).not.toContain(otherName); 337 + // Tag filter is shown in the Filter line 338 + expect(ss.text).toContain("#layout=work"); 339 + }, 340 + 15000 341 + ); 342 + 343 + it( 276 344 "arrow keys move selection", 277 345 async () => { 278 346 const sessionDir = makeSessionDir();
+114
tests/up-down.test.ts
··· 34 34 35 35 function runCli(sessionDir: string, ...args: string[]): { status: number | null; stdout: string; stderr: string } { 36 36 const result = spawnSync(nodeBin, [cliPath, ...args], { 37 + cwd: os.tmpdir(), 37 38 env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 38 39 encoding: "utf-8", 39 40 timeout: 15000, ··· 188 189 expect(session.tags.role).toBe("server"); 189 190 expect(session.tags.custom).toBe("yes"); 190 191 }, 15000); 192 + 193 + it("removes tags that were removed from pty.toml", () => { 194 + const projDir = makeProjectDir(); 195 + const sessDir = makeSessionDir(); 196 + 197 + writePtyToml(projDir, ` 198 + [sessions.remover] 199 + command = "cat" 200 + tags = { role = "server", env = "dev" } 201 + `); 202 + runCli(sessDir, "up", projDir); 203 + 204 + let sessions = listJson(sessDir); 205 + let session = sessions.find((s: any) => s.name === "remover"); 206 + expect(session.tags.role).toBe("server"); 207 + expect(session.tags.env).toBe("dev"); 208 + 209 + // Remove env from the toml 210 + writePtyToml(projDir, ` 211 + [sessions.remover] 212 + command = "cat" 213 + tags = { role = "server" } 214 + `); 215 + const result = runCli(sessDir, "up", projDir); 216 + expect(result.stdout).toContain("-env"); 217 + 218 + sessions = listJson(sessDir); 219 + session = sessions.find((s: any) => s.name === "remover"); 220 + expect(session.tags.role).toBe("server"); 221 + expect(session.tags.env).toBeUndefined(); 222 + // Metadata tags should still be present 223 + expect(session.tags.ptyfile).toBeDefined(); 224 + expect(session.tags["ptyfile.session"]).toBe("remover"); 225 + }, 20000); 226 + 227 + it("removes all toml tags when the tags table is deleted", () => { 228 + const projDir = makeProjectDir(); 229 + const sessDir = makeSessionDir(); 230 + 231 + writePtyToml(projDir, ` 232 + [sessions.cleared] 233 + command = "cat" 234 + tags = { role = "server", env = "dev" } 235 + `); 236 + runCli(sessDir, "up", projDir); 237 + 238 + // Remove the tags table entirely 239 + writePtyToml(projDir, ` 240 + [sessions.cleared] 241 + command = "cat" 242 + `); 243 + const result = runCli(sessDir, "up", projDir); 244 + expect(result.stdout).toContain("-env"); 245 + expect(result.stdout).toContain("-role"); 246 + 247 + const sessions = listJson(sessDir); 248 + const session = sessions.find((s: any) => s.name === "cleared"); 249 + expect(session.tags.role).toBeUndefined(); 250 + expect(session.tags.env).toBeUndefined(); 251 + expect(session.tags.ptyfile).toBeDefined(); 252 + }, 20000); 253 + 254 + it("preserves manually-added tags when toml tags are removed", () => { 255 + const projDir = makeProjectDir(); 256 + const sessDir = makeSessionDir(); 257 + 258 + writePtyToml(projDir, ` 259 + [sessions.mixer] 260 + command = "cat" 261 + tags = { role = "server" } 262 + `); 263 + runCli(sessDir, "up", projDir); 264 + 265 + // Add a manual tag 266 + runCli(sessDir, "tag", "mixer", "custom=yes"); 267 + 268 + // Remove the toml tag 269 + writePtyToml(projDir, ` 270 + [sessions.mixer] 271 + command = "cat" 272 + `); 273 + runCli(sessDir, "up", projDir); 274 + 275 + const sessions = listJson(sessDir); 276 + const session = sessions.find((s: any) => s.name === "mixer"); 277 + expect(session.tags.role).toBeUndefined(); 278 + expect(session.tags.custom).toBe("yes"); 279 + }, 20000); 280 + 281 + it("replaces a toml tag's value (not remove+re-add)", () => { 282 + const projDir = makeProjectDir(); 283 + const sessDir = makeSessionDir(); 284 + 285 + writePtyToml(projDir, ` 286 + [sessions.mover] 287 + command = "cat" 288 + tags = { env = "dev" } 289 + `); 290 + runCli(sessDir, "up", projDir); 291 + 292 + writePtyToml(projDir, ` 293 + [sessions.mover] 294 + command = "cat" 295 + tags = { env = "prod" } 296 + `); 297 + const result = runCli(sessDir, "up", projDir); 298 + expect(result.stdout).toContain("env=prod"); 299 + expect(result.stdout).not.toContain("-env"); 300 + 301 + const sessions = listJson(sessDir); 302 + const session = sessions.find((s: any) => s.name === "mover"); 303 + expect(session.tags.env).toBe("prod"); 304 + }, 20000); 191 305 192 306 it("no output for already-running sessions with matching tags", () => { 193 307 const projDir = makeProjectDir();