This repository has no description
0

Configure Feed

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

Integrate pty-relay into interactive list and pty list --remote

Interactive TUI discovers pty-relay on PATH and fetches remote
sessions async via ls --json. Renders local + remote groups using
groupedSelectable. Enter on a remote session spawns pty-relay
connect with pause/resume. pty list --remote appends remote hosts.
Graceful degradation when pty-relay is not installed.

Nathan Herald (Apr 14, 2026, 4:53 PM +0200) 3b966278 5b0c5f87

+220 -42
+38 -5
src/cli.ts
··· 388 388 case "ls": { 389 389 const jsonFlag = args.includes("--json"); 390 390 const tagsFlag = args.includes("--tags"); 391 - await cmdList(jsonFlag, tagsFlag); 391 + const remoteFlag = args.includes("--remote"); 392 + await cmdList(jsonFlag, tagsFlag, remoteFlag); 392 393 break; 393 394 } 394 395 ··· 896 897 }); 897 898 } 898 899 899 - async function cmdList(json = false, showTags = false): Promise<void> { 900 + async function cmdList(json = false, showTags = false, remote = false): Promise<void> { 900 901 const sessions = await listSessions(); 901 902 903 + // Fetch relay hosts if --remote 904 + let remoteHosts: { label: string; sessions: { name: string; status: string; command?: string; cwd?: string }[]; error: string | null }[] = []; 905 + if (remote) { 906 + try { 907 + const relayBin = execFileSync("which", ["pty-relay"], { encoding: "utf-8" }).trim(); 908 + const result = spawnSync(relayBin, ["ls", "--json"], { encoding: "utf-8", timeout: 5000 }); 909 + if (result.status === 0 && result.stdout.trim()) { 910 + remoteHosts = JSON.parse(result.stdout); 911 + } 912 + } catch {} 913 + } 914 + 902 915 if (json) { 903 - const output = sessions.map((s) => ({ 916 + const localOutput = sessions.map((s) => ({ 904 917 name: s.name, 905 918 status: s.status, 906 919 pid: s.pid, ··· 913 926 exitedAt: s.metadata?.exitedAt ?? null, 914 927 ...(s.metadata?.tags ? { tags: s.metadata.tags } : {}), 915 928 })); 916 - console.log(JSON.stringify(output)); 929 + if (remote && remoteHosts.length > 0) { 930 + console.log(JSON.stringify({ local: localOutput, remote: remoteHosts })); 931 + } else { 932 + console.log(JSON.stringify(localOutput)); 933 + } 917 934 return; 918 935 } 919 936 920 - if (sessions.length === 0) { 937 + if (sessions.length === 0 && remoteHosts.length === 0) { 921 938 console.log("No active sessions."); 922 939 return; 923 940 } ··· 958 975 : ""; 959 976 const marker = strategyMarker(meta?.tags); 960 977 console.log(` \x1b[1m${session.name}\x1b[0m${marker}${tagStr} (exited with code ${code}, ${ago}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); 978 + } 979 + } 980 + 981 + // Remote hosts 982 + for (const host of remoteHosts) { 983 + console.log(""); 984 + if (host.error) { 985 + console.log(`\x1b[1m${host.label}\x1b[0m \x1b[31m(error: ${host.error})\x1b[0m`); 986 + continue; 987 + } 988 + console.log(`\x1b[1m${host.label}\x1b[0m (${host.sessions.length} sessions):`); 989 + for (const s of host.sessions) { 990 + const icon = s.status === "running" ? "\u25cf" : "\u25cb"; 991 + const cwd = s.cwd ? shortPath(s.cwd) : ""; 992 + const cmd = s.command ?? ""; 993 + console.log(` \x1b[1;36m${icon} ${s.name}\x1b[0m — ${cwd} — \x1b[2m${cmd}\x1b[0m`); 961 994 } 962 995 } 963 996 }
+182 -37
src/tui/interactive.ts
··· 1 1 // Interactive session list — built with the declarative TUI framework + app() 2 2 import * as fs from "node:fs"; 3 3 import * as path from "node:path"; 4 + import { execFileSync, spawn as spawnChild, spawnSync } from "node:child_process"; 4 5 import { attach } from "../client.ts"; 5 6 import { 6 7 listSessions, validateName, acquireLock, releaseLock, ··· 10 11 import { 11 12 app, screen, signal, computed, batch, 12 13 text, row, spacer, panel, selectable, footer, canvas, 14 + groupedSelectable, type SelectableGroup, 13 15 updateScrollRegion, themes, 14 16 type KeyEvent, type ScreenContext, type UINode, 15 17 } from "./index.ts"; ··· 85 87 const existingNames = signal<Set<string>>(new Set()); 86 88 87 89 // ============================================================ 90 + // Relay integration 91 + // ============================================================ 92 + 93 + interface RemoteSession { 94 + name: string; 95 + status: string; 96 + command?: string; 97 + cwd?: string; 98 + } 99 + 100 + interface RelayHost { 101 + label: string; 102 + url: string; 103 + sessions: RemoteSession[]; 104 + spawn_enabled: boolean; 105 + error: string | null; 106 + } 107 + 108 + let relayBin: string | null = null; 109 + try { 110 + relayBin = execFileSync("which", ["pty-relay"], { encoding: "utf-8" }).trim(); 111 + } catch {} 112 + 113 + const relayHosts = signal<RelayHost[]>([]); 114 + 115 + /** Fetch remote sessions from pty-relay asynchronously. 116 + * Updates the relayHosts signal when data arrives, triggering a re-render. */ 117 + function refreshRelayHosts(): void { 118 + if (!relayBin) return; 119 + const child = spawnChild(relayBin, ["ls", "--json"], { 120 + stdio: ["ignore", "pipe", "ignore"], 121 + timeout: 10000, 122 + }); 123 + let stdout = ""; 124 + child.stdout?.on("data", (d: Buffer) => { stdout += d.toString(); }); 125 + child.on("close", (code) => { 126 + if (code === 0 && stdout.trim()) { 127 + try { 128 + relayHosts.set(JSON.parse(stdout)); 129 + } catch {} 130 + } 131 + }); 132 + } 133 + 134 + // ============================================================ 88 135 // Computed values 89 136 // ============================================================ 90 137 91 138 interface ListItem { 92 - type: "session" | "create"; 139 + type: "session" | "create" | "remote"; 93 140 session?: SessionInfo; 141 + remote?: { host: RelayHost; session: RemoteSession }; 94 142 } 95 143 96 144 const sortedSessions = computed<SessionInfo[]>(() => sortSessions(sessions.get())); 97 145 98 - const filteredItems = computed<ListItem[]>(() => { 99 - const filter = filterText.get(); 146 + function filterAndSort(filter: string, items: ListItem[]): ListItem[] { 147 + if (!filter) return items; 100 148 const matches: { item: ListItem; score: number }[] = []; 101 - for (const s of sortedSessions.get()) { 102 - if (!filter) { 103 - matches.push({ item: { type: "session", session: s }, score: 0 }); 104 - continue; 149 + for (const item of items) { 150 + let name = ""; 151 + let cmd = ""; 152 + let cwd = ""; 153 + if (item.type === "session" && item.session) { 154 + name = item.session.name; 155 + cmd = item.session.metadata?.displayCommand ?? ""; 156 + cwd = item.session.metadata?.cwd ?? ""; 157 + } else if (item.type === "remote" && item.remote) { 158 + name = item.remote.session.name; 159 + cmd = item.remote.session.command ?? ""; 160 + cwd = item.remote.session.cwd ?? ""; 161 + } else { 162 + continue; // skip "create" items during filter 105 163 } 106 - const cmd = s.metadata 107 - ? s.metadata.displayCommand ?? "" 108 - : ""; 109 - const cwd = s.metadata?.cwd ?? ""; 110 - // Fuzzy match against name (weighted highest), cwd, and command 111 - const nameResult = fuzzyMatch(filter, s.name); 164 + const nameResult = fuzzyMatch(filter, name); 112 165 const cwdResult = fuzzyMatch(filter, cwd); 113 166 const cmdResult = fuzzyMatch(filter, cmd); 114 167 if (!nameResult.match && !cwdResult.match && !cmdResult.match) continue; 115 - // Name matches get a large bonus so they always rank above cwd/cmd matches. 116 - // Running sessions get an extra bonus so they always rank above exited ones. 117 - const runningBonus = s.status === "running" ? 100000 : 0; 168 + const runningBonus = (item.type === "session" && item.session?.status === "running") || (item.type === "remote" && item.remote?.session.status === "running") ? 100000 : 0; 118 169 const score = runningBonus + Math.max( 119 170 nameResult.match ? nameResult.score + 10000 : 0, 120 171 cwdResult.match ? cwdResult.score : 0, 121 172 cmdResult.match ? cmdResult.score : 0, 122 173 ); 123 - matches.push({ item: { type: "session", session: s }, score }); 174 + matches.push({ item, score }); 124 175 } 125 - // Higher score = better match = first in list 126 176 matches.sort((a, b) => b.score - a.score); 127 - const items = matches.map(m => m.item); 128 - items.push({ type: "create" }); 129 - return items; 177 + return matches.map(m => m.item); 178 + } 179 + 180 + const filteredGroups = computed<SelectableGroup<ListItem>[]>(() => { 181 + const filter = filterText.get(); 182 + 183 + // Local group 184 + const localItems: ListItem[] = sortedSessions.get().map(s => ({ type: "session" as const, session: s })); 185 + const filteredLocal = filter ? filterAndSort(filter, localItems) : localItems; 186 + // Always add "Create new session" at the end of local group 187 + const localWithCreate: ListItem[] = [...filteredLocal, { type: "create" }]; 188 + 189 + const groups: SelectableGroup<ListItem>[] = [ 190 + { title: "Local", items: localWithCreate }, 191 + ]; 192 + 193 + // Remote groups from relay 194 + for (const host of relayHosts.get()) { 195 + if (host.error) continue; 196 + const remoteItems: ListItem[] = host.sessions.map(s => ({ 197 + type: "remote" as const, 198 + remote: { host, session: s }, 199 + })); 200 + const filtered = filter ? filterAndSort(filter, remoteItems) : remoteItems; 201 + if (filtered.length > 0 || !filter) { 202 + groups.push({ title: host.label, items: filtered }); 203 + } 204 + } 205 + 206 + return groups; 207 + }); 208 + 209 + // Total item count across all groups (for scroll region) 210 + const totalItems = computed(() => { 211 + return filteredGroups.get().reduce((sum, g) => sum + g.items.length, 0); 130 212 }); 131 213 132 214 // ============================================================ ··· 138 220 if (item.type === "create") { 139 221 return [text(sel + "+ Create new session...", selected ? "accent" : "muted", { bold: selected, truncate: true })]; 140 222 } 223 + 224 + if (item.type === "remote" && item.remote) { 225 + const rs = item.remote.session; 226 + const icon = rs.status === "running" ? "\u25cf" : "\u25cb"; 227 + const cwdStr = rs.cwd ? shortPath(rs.cwd) : ""; 228 + const cmd = rs.command ?? ""; 229 + const nameStr = `${sel}${icon} ${rs.name}`; 230 + const detailStr = ` ${cwdStr} ${cmd}`; 231 + const line = nameStr + detailStr; 232 + 233 + if (selected) { 234 + return [text(line, "accent", { bold: true, truncate: true })]; 235 + } 236 + return [ 237 + text(nameStr, rs.status === "running" ? "primary" : "muted", { bold: true }), 238 + text(detailStr, "muted", { dim: true, truncate: true }), 239 + ]; 240 + } 241 + 141 242 const s = item.session!; 142 243 const icon = s.status === "running" ? "\u25cf" : "\u25cb"; 143 244 const cmd = s.metadata ··· 152 253 const supStatus = s.metadata?.tags?.["supervisor.status"]; 153 254 const strategy = s.metadata?.tags?.strategy; 154 255 const marker = supStatus === "failed" ? " [failed]" : strategy === "permanent" ? " [permanent]" : strategy === "temporary" ? " [temporary]" : ""; 155 - const markerColor: "error" | "warn" | "muted" = supStatus === "failed" ? "error" : strategy ? "warn" : "muted"; 156 - const iconColor = selected ? "accent" : s.status === "running" ? "ok" : "muted"; 157 256 158 - const parts: string[] = [ 159 - `${sel}${icon} `, 160 - ]; 161 - // Build as a single string but use dim for path/command 162 257 const nameStr = `${sel}${icon} ${s.name}${marker}`; 163 258 const detailStr = ` ${pathStr} ${cmd}`; 164 259 const line = nameStr + detailStr; ··· 166 261 if (selected) { 167 262 return [text(line, "accent", { bold: true, truncate: true })]; 168 263 } 169 - // Use two text nodes: bold name + dim details 170 264 return [ 171 265 text(nameStr, s.status === "running" ? "primary" : "muted", { bold: true }), 172 266 text(detailStr, "muted", { dim: true, truncate: true }), ··· 177 271 id: "list", 178 272 179 273 render(ctx: ScreenContext): UINode[] { 180 - const items = filteredItems.get(); 274 + const groups = filteredGroups.get(); 275 + const total = totalItems.get(); 181 276 const viewport = Math.max(1, ctx.rows - 6); 182 277 const region = updateScrollRegion( 183 - { offset: 0, selectedIndex: selectedIndex.get(), totalItems: items.length, viewportHeight: viewport }, 184 - items.length, 278 + { offset: 0, selectedIndex: selectedIndex.get(), totalItems: total, viewportHeight: viewport }, 279 + total, 185 280 viewport, 186 281 ); 187 282 ··· 190 285 ? text(" Filter: " + filter, "primary") 191 286 : text(" Filter: (type to filter)", "muted", { dim: true }); 192 287 288 + const hasRelay = relayHosts.get().length > 0; 289 + 193 290 return [ 194 291 panel("pty", [ 195 292 filterLine, 196 293 text("", "muted"), // blank line 197 - selectable(region, items, renderListItem), 294 + hasRelay 295 + ? groupedSelectable(region, groups, renderListItem) 296 + : groupedSelectable(region, groups, renderListItem, () => []), 198 297 ]), 199 298 footer(`\u2191\u2193 select \u23ce attach ctrl+g theme (${themeNames[themeIndex.get()]}) q quit`), 200 299 ]; 201 300 }, 202 301 203 302 handleKey(key: KeyEvent, _ctx: ScreenContext): boolean { 204 - const items = filteredItems.get(); 303 + const total = totalItems.get(); 205 304 const idx = selectedIndex.peek(); 206 - const maxIndex = items.length - 1; 305 + const maxIndex = total - 1; 306 + 307 + // Resolve the item at the current global index across all groups 308 + function getItemAtIndex(globalIdx: number): ListItem | null { 309 + let i = 0; 310 + for (const group of filteredGroups.get()) { 311 + for (const item of group.items) { 312 + if (i === globalIdx) return item; 313 + i++; 314 + } 315 + } 316 + return null; 317 + } 207 318 208 319 if (key.name === "up") { 209 320 selectedIndex.set(Math.max(0, idx - 1)); ··· 214 325 return true; 215 326 } 216 327 if (key.name === "return") { 217 - const item = items[idx]; 328 + const item = getItemAtIndex(idx); 218 329 if (!item) return true; 219 330 if (item.type === "create") { 220 331 batch(() => { ··· 223 334 createSelectedIndex.set(0); 224 335 existingNames.set(new Set(sessions.peek().map(s => s.name))); 225 336 }); 337 + return true; 338 + } 339 + if (item.type === "remote" && item.remote) { 340 + doAttachRemote(item.remote.host, item.remote.session); 226 341 return true; 227 342 } 228 343 if (item.session) { ··· 536 651 doAttach(session.name); 537 652 } 538 653 654 + function doAttachRemote(host: RelayHost, session: RemoteSession): void { 655 + if (!relayBin) return; 656 + myApp?.pause(); 657 + 658 + // Build the connect URL: base URL + /session-name 659 + const url = host.url.replace(/#.*$/, "") + "/" + session.name + 660 + (host.url.includes("#") ? "#" + host.url.split("#").slice(1).join("#") : ""); 661 + 662 + const result = spawnSync(relayBin, ["connect", url], { 663 + stdio: "inherit", 664 + }); 665 + 666 + // Refresh and resume 667 + (async () => { 668 + const updated = await listSessions(); 669 + refreshRelayHosts(); 670 + batch(() => { 671 + sessions.set(updated); 672 + currentScreen.set("list"); 673 + filterText.set(""); 674 + const maxIdx = Math.max(0, totalItems.get() - 1); 675 + if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); 676 + }); 677 + myApp?.resume(); 678 + })(); 679 + } 680 + 539 681 function doAttach(name: string): void { 540 682 myApp?.pause(); 541 683 attach({ ··· 546 688 sessions.set(updated); 547 689 currentScreen.set("list"); 548 690 filterText.set(""); 549 - const maxIdx = Math.max(0, filteredItems.get().length - 1); 691 + const maxIdx = Math.max(0, totalItems.get() - 1); 550 692 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); 551 693 }); 552 694 myApp?.resume(); ··· 559 701 sessions.set(updated); 560 702 currentScreen.set("list"); 561 703 filterText.set(""); 562 - const maxIdx = Math.max(0, filteredItems.get().length - 1); 704 + const maxIdx = Math.max(0, totalItems.get() - 1); 563 705 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); 564 706 }); 565 707 myApp?.resume(); ··· 634 776 export async function runInteractive(): Promise<void> { 635 777 const sessionList = await listSessions(); 636 778 sessions.set(sessionList); 779 + 780 + // Fetch relay hosts in the background (non-blocking) 781 + refreshRelayHosts(); 637 782 638 783 myApp = app({ 639 784 screen: () => currentScreen.get() === "list" ? listScreen : createScreen,