This repository has no description
0

Configure Feed

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

Better tag support overall

Nathan Herald (Apr 15, 2026, 6:19 PM +0200) cf443a91 c1b1a224

+190 -10
+1
CHANGELOG.md
··· 7 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 (local and remote) stay in the filtered view (e.g., pty-layout layouts) 8 8 - Remote session spawns forward filter tags to pty-relay as `--tag key=value` so remote sessions created from a filtered TUI are tagged on the remote side and stay in the filtered view 9 9 - Tag filter is shown in the Filter line; remote groups are filtered by their `tags` field when a tag filter is active 10 + - Session rows in the interactive list now show user-facing tags inline (`#key=value`) alongside cwd and command (matches `pty list` output) 10 11 11 12 ### Listing 12 13 - `pty list` now shows tags by default (hashtag format, e.g., `#role=web`) — internal bookkeeping keys (`ptyfile*`, `strategy`, `supervisor.status`) are hidden
+27 -9
src/tui/interactive.ts
··· 274 274 // List screen 275 275 // ============================================================ 276 276 277 + /** Render user-facing tags as a " #key=value" string. Hides internal 278 + * bookkeeping keys that already have dedicated markers or aren't meaningful. */ 279 + function renderTagsInline(tags: Record<string, string> | undefined): string { 280 + if (!tags) return ""; 281 + const entries = Object.entries(tags).filter(([k]) => 282 + k !== "ptyfile" && k !== "ptyfile.session" && k !== "ptyfile.tags" && 283 + k !== "supervisor.status" && k !== "strategy", 284 + ); 285 + return entries.length > 0 ? " " + entries.map(([k, v]) => `#${k}=${v}`).join(" ") : ""; 286 + } 287 + 277 288 function renderListItem(item: ListItem, _index: number, selected: boolean): UINode[] { 278 289 const sel = selected ? "\u25b8 " : " "; 279 290 if (item.type === "create") { ··· 289 300 const icon = rs.status === "running" ? "\u25cf" : "\u25cb"; 290 301 const cwdStr = rs.cwd ? shortPath(rs.cwd) : ""; 291 302 const cmd = rs.command ?? ""; 292 - const nameStr = `${sel}${icon} ${rs.name}`; 303 + const tagStr = renderTagsInline(rs.tags); 304 + const nameStr = `${sel}${icon} ${rs.name}${tagStr}`; 293 305 const detailStr = ` ${cwdStr} ${cmd}`; 294 306 const line = nameStr + detailStr; 295 307 ··· 316 328 const supStatus = s.metadata?.tags?.["supervisor.status"]; 317 329 const strategy = s.metadata?.tags?.strategy; 318 330 const marker = supStatus === "failed" ? " [failed]" : strategy === "permanent" ? " [permanent]" : strategy === "temporary" ? " [temporary]" : ""; 331 + const tagStr = renderTagsInline(s.metadata?.tags); 319 332 320 - const nameStr = `${sel}${icon} ${s.name}${marker}`; 333 + const nameStr = `${sel}${icon} ${s.name}${marker}${tagStr}`; 321 334 const detailStr = ` ${pathStr} ${cmd}`; 322 335 const line = nameStr + detailStr; 323 336 ··· 755 768 })(); 756 769 } 757 770 771 + /** Build the argv for `pty-relay connect <url> --spawn <name>` with tags 772 + * forwarded as `--tag key=value`. Exported for unit testing. */ 773 + export function buildSpawnRemoteArgs(url: string, name: string, tags: Record<string, string>): string[] { 774 + const argv = ["connect", url, "--spawn", name]; 775 + for (const [k, v] of Object.entries(tags)) { 776 + argv.push("--tag", `${k}=${v}`); 777 + } 778 + return argv; 779 + } 780 + 758 781 function doSpawnRemote(host: RelayHost, name: string): void { 759 782 if (!relayBin) return; 760 783 myApp?.pause(); 761 784 762 785 // Forward --filter-tag tags to the relay so the newly-spawned remote 763 786 // session is tagged and stays within the filtered view. 764 - const tags = filterTags.peek(); 765 - const tagArgs: string[] = []; 766 - for (const [k, v] of Object.entries(tags)) { 767 - tagArgs.push("--tag", `${k}=${v}`); 768 - } 769 - 770 - const result = spawnSync(relayBin, ["connect", host.url, "--spawn", name, ...tagArgs], { 787 + const argv = buildSpawnRemoteArgs(host.url, name, filterTags.peek()); 788 + const result = spawnSync(relayBin, argv, { 771 789 stdio: "inherit", 772 790 }); 773 791
+31 -1
tests/filter.test.ts
··· 1 1 import { describe, it, expect } from "vitest"; 2 - import { buildFilteredGroups, type ListItem, type RelayHost } from "../src/tui/interactive.ts"; 2 + import { buildFilteredGroups, buildSpawnRemoteArgs, type ListItem, type RelayHost } from "../src/tui/interactive.ts"; 3 3 import type { SessionInfo } from "../src/sessions.ts"; 4 4 5 5 function makeSession(name: string, status: "running" | "exited" = "running", opts?: { command?: string; cwd?: string; tags?: Record<string, string> }): SessionInfo { ··· 225 225 }); 226 226 }); 227 227 }); 228 + 229 + describe("buildSpawnRemoteArgs", () => { 230 + it("builds the base argv with no tags", () => { 231 + expect(buildSpawnRemoteArgs("https://host#tok", "myses", {})).toEqual([ 232 + "connect", "https://host#tok", "--spawn", "myses", 233 + ]); 234 + }); 235 + 236 + it("forwards tags as --tag key=value pairs", () => { 237 + expect(buildSpawnRemoteArgs("https://host", "myses", { role: "web" })).toEqual([ 238 + "connect", "https://host", "--spawn", "myses", "--tag", "role=web", 239 + ]); 240 + }); 241 + 242 + it("forwards multiple tags in a stable order", () => { 243 + const tags = { role: "web", env: "prod" }; 244 + const argv = buildSpawnRemoteArgs("https://h", "s", tags); 245 + expect(argv).toEqual([ 246 + "connect", "https://h", "--spawn", "s", 247 + "--tag", "role=web", 248 + "--tag", "env=prod", 249 + ]); 250 + }); 251 + 252 + it("preserves = in values", () => { 253 + expect(buildSpawnRemoteArgs("u", "n", { note: "k=v" })).toEqual([ 254 + "connect", "u", "--spawn", "n", "--tag", "note=k=v", 255 + ]); 256 + }); 257 + });
+47
tests/tags.test.ts
··· 152 152 expect(sessions.map((s: any) => s.name)).toEqual([bothName]); 153 153 }, 15000); 154 154 155 + it("pty list shows tags by default as hashtags (no --tags needed)", async () => { 156 + const dir = makeSessionDir(); 157 + const name = uniqueName(); 158 + await startDaemon(dir, name, "cat", [], { role: "web", env: "dev" }); 159 + 160 + const output = runCli(dir, "list"); 161 + expect(output).toContain(name); 162 + expect(output).toContain("#role=web"); 163 + expect(output).toContain("#env=dev"); 164 + }, 15000); 165 + 166 + it("pty list hides internal bookkeeping tags by default", async () => { 167 + const dir = makeSessionDir(); 168 + const name = uniqueName(); 169 + await startDaemon(dir, name, "cat", [], { 170 + role: "web", 171 + ptyfile: "/some/path/pty.toml", 172 + "ptyfile.session": "s", 173 + "ptyfile.tags": "role", 174 + strategy: "permanent", 175 + }); 176 + 177 + const output = runCli(dir, "list"); 178 + expect(output).toContain("#role=web"); 179 + expect(output).not.toContain("#ptyfile="); 180 + expect(output).not.toContain("#ptyfile.session="); 181 + expect(output).not.toContain("#ptyfile.tags="); 182 + expect(output).not.toContain("#strategy="); 183 + // strategy still surfaces via the [permanent] marker 184 + expect(output).toContain("[permanent]"); 185 + }, 15000); 186 + 187 + it("pty list --tags includes internal bookkeeping tags", async () => { 188 + const dir = makeSessionDir(); 189 + const name = uniqueName(); 190 + await startDaemon(dir, name, "cat", [], { 191 + role: "web", 192 + ptyfile: "/some/path/pty.toml", 193 + strategy: "permanent", 194 + }); 195 + 196 + const output = runCli(dir, "list", "--tags"); 197 + expect(output).toContain("#role=web"); 198 + expect(output).toContain("#ptyfile="); 199 + expect(output).toContain("#strategy=permanent"); 200 + }, 15000); 201 + 155 202 it("pty list --filter-tag filters text output too", async () => { 156 203 const dir = makeSessionDir(); 157 204 const matchName = uniqueName();
+84
tests/tui.test.ts
··· 305 305 ); 306 306 307 307 it( 308 + "interactive list renders user tags inline as #key=value next to session name", 309 + async () => { 310 + const sessionDir = makeSessionDir(); 311 + const name = uniqueName(); 312 + 313 + const { pid } = await createBackgroundSession(sessionDir, name, "sh", ["-c", "sleep 300"], os.tmpdir()); 314 + bgPids.push(pid); 315 + 316 + // Tag the session via the CLI 317 + spawn(nodeBin, [cliPath, "tag", name, "role=web"], { 318 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 319 + stdio: "ignore", 320 + }).on("exit", () => {}); 321 + await new Promise((r) => setTimeout(r, 500)); 322 + 323 + const tui = createTuiSession(sessionDir); 324 + const ss = await tui.waitForText("#role=web", 10000); 325 + const sessionLine = ss.lines.find((l) => l.includes(name)); 326 + expect(sessionLine).toBeDefined(); 327 + // Tag sits between the name and the rest of the detail (cwd/command) 328 + const nameIdx = sessionLine!.indexOf(name); 329 + const tagIdx = sessionLine!.indexOf("#role=web"); 330 + expect(tagIdx).toBeGreaterThan(nameIdx); 331 + // Internal bookkeeping keys should not render as hashtags 332 + expect(ss.text).not.toContain("#ptyfile="); 333 + }, 334 + 15000 335 + ); 336 + 337 + it( 338 + "--filter-tag auto-applies the tag to sessions created from the TUI", 339 + async () => { 340 + const sessionDir = makeSessionDir(); 341 + 342 + const tui = Session.spawn(nodeBin, [cliPath, "--preselect-new", "--filter-tag", "role=web"], { 343 + rows: 24, 344 + cols: 80, 345 + env: { PTY_SESSION_DIR: sessionDir, TERM: "xterm-256color" }, 346 + }); 347 + tuiSessions.push(tui); 348 + 349 + // Create item is preselected; walk the wizard 350 + await tui.waitForText("#role=web", 10000); // filter line visible 351 + tui.press("return"); 352 + await tui.waitForText("Choose Directory", 5000); 353 + tui.press("return"); // use current directory 354 + await tui.waitForText("Name:", 5000); 355 + tui.type("cat"); 356 + await new Promise((r) => setTimeout(r, 200)); 357 + tui.press("return"); // create 358 + 359 + // Wait for the session metadata file to appear and contain the tag 360 + const start = Date.now(); 361 + let found: any = null; 362 + while (Date.now() - start < 5000) { 363 + const entries = fs.readdirSync(sessionDir).filter((f) => f.endsWith(".json")); 364 + for (const e of entries) { 365 + try { 366 + const meta = JSON.parse(fs.readFileSync(path.join(sessionDir, e), "utf-8")); 367 + if (meta.tags?.role === "web") { 368 + found = meta; 369 + break; 370 + } 371 + } catch {} 372 + } 373 + if (found) break; 374 + await new Promise((r) => setTimeout(r, 100)); 375 + } 376 + expect(found).not.toBeNull(); 377 + expect(found.tags.role).toBe("web"); 378 + 379 + // Clean up the spawned session 380 + const pidFiles = fs.readdirSync(sessionDir).filter((f) => f.endsWith(".pid")); 381 + for (const p of pidFiles) { 382 + try { 383 + const pid = parseInt(fs.readFileSync(path.join(sessionDir, p), "utf-8").trim(), 10); 384 + bgPids.push(pid); 385 + } catch {} 386 + } 387 + }, 388 + 20000 389 + ); 390 + 391 + it( 308 392 "--filter-tag hides sessions that lack the tag", 309 393 async () => { 310 394 const sessionDir = makeSessionDir();