This repository has no description
0

Configure Feed

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

peek --plain and send --with-delay

Nathan Herald (Mar 10, 2026, 10:43 AM +0100) d6e4fdce 981731ef

+172 -22
+2
completions/pty.zsh
··· 43 43 peek) 44 44 _arguments \ 45 45 '(-f --follow)'{-f,--follow}'[Follow output read-only]' \ 46 + '--plain[Output plain text without ANSI codes]' \ 46 47 '1:session:_pty_sessions' 47 48 ;; 48 49 send) 49 50 _arguments \ 50 51 '1:session:_pty_sessions' \ 52 + '--with-delay[Delay between --seq items (seconds)]:seconds:' \ 51 53 '*--seq[Send a sequence item]:value:' 52 54 ;; 53 55 kill|restart)
+30 -7
src/cli.ts
··· 29 29 pty attach <name> Attach to an existing session 30 30 pty attach -r <name> Attach, auto-restart if exited 31 31 pty peek <name> Print current screen and exit 32 + pty peek --plain <name> Print current screen as plain text (no ANSI) 32 33 pty peek -f <name> Follow output read-only (Ctrl+\\ to stop) 33 34 pty send <name> "text" Send text to a session 34 35 pty send <name> --seq "text" --seq key:return Send an ordered sequence 36 + pty send <name> --with-delay 0.5 --seq ... Delay between each --seq item 35 37 pty restart <name> Restart an exited session 36 38 pty list List active sessions 37 39 pty list --json List sessions as JSON ··· 119 121 } 120 122 121 123 case "peek": { 122 - const follow = args[1] === "-f" || args[1] === "--follow"; 123 - const peekName = follow ? args[2] : args[1]; 124 + let follow = false; 125 + let plain = false; 126 + let pi = 1; 127 + while (pi < args.length && args[pi].startsWith("-")) { 128 + if (args[pi] === "-f" || args[pi] === "--follow") follow = true; 129 + else if (args[pi] === "--plain") plain = true; 130 + else break; 131 + pi++; 132 + } 133 + const peekName = args[pi]; 124 134 if (!peekName) { 125 - console.error("Usage: pty peek [-f] <name>"); 135 + console.error("Usage: pty peek [-f] [--plain] <name>"); 126 136 process.exit(1); 127 137 } 128 138 try { ··· 131 141 console.error(e.message); 132 142 process.exit(1); 133 143 } 134 - cmdPeek(peekName, follow); 144 + cmdPeek(peekName, follow, plain); 135 145 break; 136 146 } 137 147 ··· 148 158 process.exit(1); 149 159 } 150 160 151 - const sendArgs = args.slice(2); 161 + let sendArgs = args.slice(2); 162 + let delaySecs: number | undefined; 163 + if (sendArgs[0] === "--with-delay") { 164 + sendArgs = sendArgs.slice(1); 165 + const val = parseFloat(sendArgs[0]); 166 + if (isNaN(val) || val < 0) { 167 + console.error("--with-delay requires a non-negative number (seconds)."); 168 + process.exit(1); 169 + } 170 + delaySecs = val; 171 + sendArgs = sendArgs.slice(1); 172 + } 173 + 152 174 const hasSeq = sendArgs.includes("--seq"); 153 175 const hasPositional = sendArgs.length > 0 && !sendArgs[0].startsWith("--"); 154 176 ··· 180 202 process.exit(1); 181 203 } 182 204 183 - send({ name: sendName, data }); 205 + send({ name: sendName, data, delayMs: delaySecs != null ? delaySecs * 1000 : undefined }); 184 206 break; 185 207 } 186 208 ··· 350 372 }); 351 373 } 352 374 353 - function cmdPeek(name: string, follow: boolean): void { 375 + function cmdPeek(name: string, follow: boolean, plain: boolean): void { 354 376 peek({ 355 377 name, 356 378 follow, 379 + plain, 357 380 onDetach: () => process.exit(0), 358 381 onExit: (code) => process.exit(code), 359 382 });
+19 -10
src/client.ts
··· 47 47 "\x1b[0 q" + // reset cursor style to terminal default 48 48 "\x1b>" + // reset application keypad mode (DECKPNM) 49 49 "\x1b(B" + // reset G0 character set to ASCII 50 - "\x1b[<u"; // pop Kitty keyboard protocol mode 50 + "\x1b[<99u"; // pop all Kitty keyboard protocol levels 51 + 52 + // Move cursor to bottom of visible screen so status messages (e.g. 53 + // "[detached]") appear below the session content, not mid-screen. 54 + const CURSOR_TO_BOTTOM = "\x1b[999;1H"; 51 55 52 56 export interface PeekOptions { 53 57 name: string; 54 58 follow?: boolean; // If true, stay connected and stream (like tail -f). If false, print screen and exit. 59 + plain?: boolean; // If true, output plain text without ANSI codes. 55 60 onExit?: (code: number) => void; 56 61 onDetach?: () => void; 57 62 } ··· 65 70 const follow = options.follow ?? false; 66 71 67 72 socket.on("connect", () => { 68 - socket.write(encodePeek()); 73 + socket.write(encodePeek(options.plain)); 69 74 70 75 if (follow) { 71 76 // In follow mode, Ctrl+\ detaches ··· 78 83 if (data[i] === DETACH_KEY) { 79 84 if (stdin.isTTY) stdin.setRawMode(false); 80 85 socket.destroy(); 81 - stdout.write(TERMINAL_SANITIZE + "\r\n[detached]\r\n"); 86 + stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + "\r\n[detached]\r\n"); 82 87 options.onDetach?.(); 83 88 return; 84 89 } ··· 96 101 case MessageType.SCREEN: 97 102 stdout.write(packet.payload); 98 103 if (!follow) { 99 - stdout.write(TERMINAL_SANITIZE + "\n"); 104 + stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + "\n"); 100 105 socket.destroy(); 101 106 return; 102 107 } ··· 111 116 case MessageType.EXIT: { 112 117 const code = decodeExit(packet.payload); 113 118 socket.destroy(); 114 - stdout.write(TERMINAL_SANITIZE); 119 + stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM); 115 120 if (follow) { 116 121 stdout.write(`\r\n[session exited with code ${code}]\r\n`); 117 122 } ··· 141 146 export interface SendOptions { 142 147 name: string; 143 148 data: string[]; 149 + delayMs?: number; 144 150 } 145 151 146 152 /** Send data to a session without attaching. Silent on success. */ ··· 148 154 const socketPath = getSocketPath(options.name); 149 155 const socket = net.createConnection(socketPath); 150 156 151 - socket.on("connect", () => { 152 - for (const item of options.data) { 153 - socket.write(encodeData(item)); 157 + socket.on("connect", async () => { 158 + for (let i = 0; i < options.data.length; i++) { 159 + if (i > 0 && options.delayMs) { 160 + await new Promise((resolve) => setTimeout(resolve, options.delayMs)); 161 + } 162 + socket.write(encodeData(options.data[i])); 154 163 } 155 164 socket.end(); 156 165 }); ··· 245 254 detaching = true; 246 255 socket.write(encodeDetach()); 247 256 cleanExit(); 248 - stdout.write(TERMINAL_SANITIZE + "\r\n[detached]\r\n"); 257 + stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + "\r\n[detached]\r\n"); 249 258 options.onDetach?.(); 250 259 } 251 260 }, DOUBLE_TAP_MS); ··· 294 303 case MessageType.EXIT: 295 304 exitCode = decodeExit(packet.payload); 296 305 cleanExit(); 297 - stdout.write(TERMINAL_SANITIZE + `\r\n[session exited with code ${exitCode}]\r\n`); 306 + stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + `\r\n[session exited with code ${exitCode}]\r\n`); 298 307 options.onExit?.(exitCode); 299 308 return; 300 309 }
+4 -2
src/protocol.ts
··· 55 55 return encodePacket(MessageType.EXIT, payload); 56 56 } 57 57 58 - export function encodePeek(): Buffer { 59 - return encodePacket(MessageType.PEEK, Buffer.alloc(0)); 58 + export function encodePeek(plain = false): Buffer { 59 + const payload = Buffer.alloc(1); 60 + payload.writeUInt8(plain ? 1 : 0, 0); 61 + return encodePacket(MessageType.PEEK, payload); 60 62 } 61 63 62 64 export function encodeScreen(data: string): Buffer {
+24 -3
src/server.ts
··· 234 234 235 235 case MessageType.PEEK: { 236 236 client.readonly = true; 237 + const plain = packet.payload.length > 0 && packet.payload.readUInt8(0) === 1; 237 238 238 - // Send current screen state (same as ATTACH) 239 - const peekScreen = this.getModePrefix() + this.serialize.serialize(); 240 - socket.write(encodeScreen(peekScreen)); 239 + if (plain) { 240 + socket.write(encodeScreen(this.getPlainScreen())); 241 + } else { 242 + // Send current screen state (same as ATTACH) 243 + const peekScreen = this.getModePrefix() + this.serialize.serialize(); 244 + socket.write(encodeScreen(peekScreen)); 245 + } 241 246 242 247 if (this.exited) { 243 248 socket.write(encodeExit(this.exitCode)); ··· 318 323 for (const client of this.clients.values()) { 319 324 client.socket.write(data); 320 325 } 326 + } 327 + 328 + private getPlainScreen(): string { 329 + const buffer = this.terminal.buffer.active; 330 + const lines: string[] = []; 331 + for (let i = 0; i < buffer.length; i++) { 332 + const line = buffer.getLine(i); 333 + if (line) { 334 + lines.push(line.translateToString(true)); 335 + } 336 + } 337 + // Trim trailing empty lines 338 + while (lines.length > 0 && lines[lines.length - 1] === "") { 339 + lines.pop(); 340 + } 341 + return lines.join("\n"); 321 342 } 322 343 323 344 private getLastLines(): string[] {
+93
tests/integration.test.ts
··· 874 874 expect(screenPackets.length).toBe(0); 875 875 }); 876 876 877 + it("sends items with delay between them", async () => { 878 + const name = uniqueName(); 879 + await startServer(name, "cat"); 880 + 881 + const watcher = await connect(name); 882 + const watchReader = new PacketReader(); 883 + watcher.write(encodeAttach(24, 80)); 884 + await waitForType(watcher, watchReader, MessageType.SCREEN); 885 + 886 + // Track timestamps of received DATA packets 887 + const timestamps: number[] = []; 888 + const watchReader2 = new PacketReader(); 889 + let output = ""; 890 + watcher.on("data", (data: Buffer) => { 891 + for (const p of watchReader2.feed(data)) { 892 + if (p.type === MessageType.DATA) { 893 + timestamps.push(Date.now()); 894 + output += p.payload.toString(); 895 + } 896 + } 897 + }); 898 + 899 + // Send items with 200ms delay between each 900 + const { send } = await import("../src/client.ts"); 901 + send({ name, data: ["A", "B", "C"], delayMs: 200 }); 902 + 903 + // Wait for all items to arrive 904 + await new Promise((r) => setTimeout(r, 1000)); 905 + 906 + expect(output).toContain("A"); 907 + expect(output).toContain("B"); 908 + expect(output).toContain("C"); 909 + 910 + // There should be measurable gaps between items (at least 100ms to account for timing jitter) 911 + expect(timestamps.length).toBeGreaterThanOrEqual(2); 912 + const firstGap = timestamps[1] - timestamps[0]; 913 + expect(firstGap).toBeGreaterThanOrEqual(100); 914 + 915 + watcher.destroy(); 916 + }); 917 + 877 918 it("connection to non-existent session produces error", async () => { 878 919 const name = uniqueName(); 879 920 ··· 884 925 }); 885 926 expect(error.code).toMatch(/ENOENT|ECONNREFUSED/); 886 927 }); 928 + }); 929 + 930 + it("peek with plain flag returns text without ANSI codes", async () => { 931 + const name = uniqueName(); 932 + // Print colored output with ANSI codes 933 + await startServer(name, "sh", [ 934 + "-c", 935 + "printf '\\033[32mGREEN TEXT\\033[0m'; printf '\\033[1;31mBOLD RED\\033[0m'; sleep 30", 936 + ]); 937 + await new Promise((r) => setTimeout(r, 200)); 938 + 939 + // Normal peek — should contain ANSI escape sequences 940 + const normalPeeker = await connect(name); 941 + const normalReader = new PacketReader(); 942 + normalPeeker.write(encodePeek(false)); 943 + const normalScreen = await waitForType(normalPeeker, normalReader, MessageType.SCREEN); 944 + const normalOutput = normalScreen.payload.toString(); 945 + expect(normalOutput).toContain("GREEN TEXT"); 946 + expect(normalOutput).toContain("\x1b["); // ANSI escape present 947 + normalPeeker.destroy(); 948 + 949 + // Plain peek — should NOT contain ANSI escape sequences 950 + const plainPeeker = await connect(name); 951 + const plainReader = new PacketReader(); 952 + plainPeeker.write(encodePeek(true)); 953 + const plainScreen = await waitForType(plainPeeker, plainReader, MessageType.SCREEN); 954 + const plainOutput = plainScreen.payload.toString(); 955 + expect(plainOutput).toContain("GREEN TEXT"); 956 + expect(plainOutput).toContain("BOLD RED"); 957 + expect(plainOutput).not.toContain("\x1b["); // no ANSI escapes 958 + plainPeeker.destroy(); 959 + }); 960 + 961 + it("peek plain trims trailing blank lines", async () => { 962 + const name = uniqueName(); 963 + await startServer(name, "sh", ["-c", "echo 'only line'; sleep 30"], { 964 + rows: 24, 965 + cols: 80, 966 + }); 967 + await new Promise((r) => setTimeout(r, 200)); 968 + 969 + const peeker = await connect(name); 970 + const reader = new PacketReader(); 971 + peeker.write(encodePeek(true)); 972 + const screen = await waitForType(peeker, reader, MessageType.SCREEN); 973 + const output = screen.payload.toString(); 974 + 975 + // Should have the content but not 24 rows of blank lines 976 + expect(output).toContain("only line"); 977 + const lines = output.split("\n"); 978 + expect(lines.length).toBeLessThan(10); 979 + peeker.destroy(); 887 980 }); 888 981 889 982 // Detach/reattach: the mode was active, TERMINAL_SANITIZE popped it on