Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,14 @@ The native loader (`napi.ts`) tries glibc first, falls back to musl on Linux. On

The pure Zig library (`lib.zig`) is exposed as the `"zigpty"` module in `build.zig`:

| Function | Signature | Description |
| ---------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `forkPty` | `(ForkOptions) !ForkResult` | Fork process with PTY (forkpty + signal handling + execvpe) |
| `openPty` | `(cols, rows) !OpenResult` | Open bare PTY pair |
| `resize` | `(fd, cols, rows, x_pixel, y_pixel) !void` | Resize PTY (ioctl TIOCSWINSZ) |
| `getProcessName` | `(fd, buf) ?[]const u8` | Foreground process name via /proc |
| Function | Signature | Description |
| ---------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `forkPty` | `(ForkOptions) !ForkResult` | Fork process with PTY (forkpty + signal handling + execvpe) |
| `openPty` | `(cols, rows) !OpenResult` | Open bare PTY pair |
| `resize` | `(fd, cols, rows, x_pixel, y_pixel) !void` | Resize PTY (ioctl TIOCSWINSZ) |
| `getProcessName` | `(fd, buf) ?[]const u8` | Foreground process name via /proc |
| `getStats` | `(pid, allocator, cwd_buf) ?Stats` | Aggregate rss + cpu across the leader process and every transitive descendant (BFS by ppid). Linux: snapshots `/proc/<pid>/stat` rows once, BFS from leader by linear ppid scan. macOS: `proc_listpids(PROC_ALL_PIDS)` + `proc_pidinfo(PROC_PIDT_SHORTBSDINFO)` for the parent walk, `PROC_PIDTASKINFO` for marked pids. Returns leader cwd + totals + per-child array (caller must `stats.deinit(allocator)`). |
| `waitForExit` | `(pid) ExitInfo` | Blocking wait for child exit (call from background thread) |
| `waitForExit` | `(pid) ExitInfo` | Blocking wait for child exit (call from background thread) |

Types: `ForkOptions`, `ForkResult`, `OpenResult`, `ExitInfo`, `Stats`, `ChildStats`, `PtyError`, `Fd`, `Pid`

Expand Down
110 changes: 110 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ interface IPty {
close(): void;
waitFor(pattern: string, options?: { timeout?: number }): Promise<string>;
stats(): IPtyStats | null; // OS-level snapshot (cwd, memory, CPU time)
attach(consumer: IPtyConsumer): IDisposable; // Wire a sink to the data stream
}
```

Expand Down Expand Up @@ -180,6 +181,115 @@ await pty.exited;

Options: `{ timeout?: number }` — default 30 seconds. Throws if the pattern is not found within the timeout.

### `pty.attach(consumer)`

Wire a generic sink to the PTY's data stream. Anything with a `feed(data)` method conforms to `IPtyConsumer` — including the built-in `OSCInspector`, a file logger, an in-memory recorder, a WebSocket forwarder, etc. The consumer is auto-detached when the PTY exits.

```ts
interface IPtyConsumer {
feed(data: string | Buffer): void;
onAttach?(pty: IPty): void; // optional — fires once before the first feed
onDetach?(pty: IPty): void; // optional — fires on dispose or PTY exit
}
```

```ts
const recorder = {
chunks: [] as Buffer[],
feed(data) {
this.chunks.push(typeof data === "string" ? Buffer.from(data) : data);
},
};

const sub = pty.attach(recorder);
// ...
sub.dispose(); // detach early; otherwise auto-detached on PTY exit
```

Multiple consumers per PTY are supported and run independently.

### OSC inspector — `zigpty/osc`

Parse OSC (Operating System Command) escape sequences out of any byte stream — title changes, CWD updates, shell-integration marks (OSC 133/633), progress, notifications, and more. The inspector is a pure-TS byte-fed state machine; sequences split across chunks are stitched back together.

```ts
import { spawn } from "zigpty";
import { OSCInspector, decodeOSC } from "zigpty/osc";

const inspector = new OSCInspector((event) => {
// event = { code: number, payload: string }
const decoded = decodeOSC(event);
switch (decoded.kind) {
case "title":
console.log("title:", decoded.title);
break;
case "cwd":
// Unified across OSC 7, ConEmu 9;9, and iTerm2 1337;CurrentDir=
console.log(`cwd (${decoded.source}):`, decoded.path);
break;
case "shellIntegration":
// OSC 133/633 — command is A/B/C/D (or vscode-specific tokens)
console.log(`${decoded.vendor}/${decoded.command}`, decoded.data);
break;
case "notification":
console.log("notify:", decoded.title, decoded.body);
break;
case "progress":
// ConEmu/Windows Terminal taskbar progress (OSC 9;4)
console.log(`progress: state=${decoded.state} value=${decoded.value}`);
break;
case "mark":
// OSC 1337 SetMark / OSC 9;12 ConEmu prompt-start mark
console.log("prompt mark from", decoded.vendor);
break;
case "hyperlink":
console.log(decoded.action, decoded.uri); // "open"|"close"
break;
// ...attention, clipboard, userVar, remoteHost,
// shellIntegrationVersion, unknown
}
});

const pty = spawn("/bin/bash");
pty.attach(inspector); // OSCInspector implements IPtyConsumer
```

**Decoded shapes** (`DecodedOSC` union) cover the common codes out of the box:

| Code | `kind`(s) | Notes |
| --------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` / `1` / `2` | `title` | Window / tab / icon title. C0 control bytes stripped. |
| `7` | `cwd` (`source: "osc7"`) | `<scheme>://<host>/<path>`. Path is percent-decoded; `host`/`scheme`/`local` exposed. |
| `8` | `hyperlink` | `action: "open" \| "close"`; `id`, `uri`, and `params`. Empty URI = close. |
| `9` | `progress` / `cwd` / `mark` / `notification` | `9;4;…` progress, `9;9;…` ConEmu/WT CWD report, `9;12` prompt mark, `9;<text>` iTerm2 Growl-style notification. |
| `52` | `clipboard` | Set, query (`?`), or `clear` (Pd not base64). Multi-char `Pc` exposed via `selections[]`. |
| `99` | `notification` (`vendor: "kitty"`) | Title / body / phase (`close`, `alive`, `icon`, …); honors `i=` (id), `u=` (urgency), `d=0` (partial chunk), `e=1` (base64 payload). |
| `133` | `shellIntegration` (`vendor: "vt"`) | FinalTerm A/B/C/D. `D` parses exit code + `err=`; `A`/`C` parse kitty extras into `params`. |
| `633` | `shellIntegration` (`vendor: "vscode"`) | A/B/C/D/E/P/EnvSingleStart/EnvSingleEntry/EnvSingleEnd. Applies VSCode `\\`/`\xNN` unescaping. |
| `777` | `notification` (`vendor: "rxvt"`) | `notify;<title>;<body>` from the urxvt-perl extension. |
| `1337` | `attention` / `cwd` / `mark` / `userVar` / `remoteHost` / `clipboard` / `shellIntegrationVersion` | iTerm2: `RequestAttention` (`yes`/`no`/`once`/`fireworks`), `CurrentDir=`, `SetMark`, `SetUserVar=`, `RemoteHost=`, `Copy=`, `ShellIntegrationVersion=`. |
| _other_ | `unknown` | Raw `{code, payload}` preserved. |

**Adding custom decoders** — use `createOSCDecoder()` to register handlers for new codes (or override built-ins). The returned function is typed as `DecodedOSC | <your custom kinds>`:

```ts
import { createOSCDecoder } from "zigpty/osc";

const decode = createOSCDecoder({
// OSC 50 — terminal font (xterm), not handled by built-ins
50: (payload) => ({ kind: "font" as const, value: payload }),
// OSC 1338 — your custom vendor code
1338: (payload) => ({ kind: "vendor-x" as const, raw: payload }),
});

const inspector = new OSCInspector((event) => {
const d = decode(event);
// d: DecodedOSC | { kind: "font"; ... } | { kind: "vendor-x"; ... }
});
```

The built-in registry is exposed as `builtinOSCDecoders: Record<number, OSCDecoderFn<DecodedOSC>>` if you want to inspect or reuse individual decoders.

### `hasNative`

Boolean — `true` when native Zig PTY bindings loaded successfully, `false` when running in pipe fallback mode.
Expand Down
2 changes: 1 addition & 1 deletion build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default defineBuildConfig({
entries: [
{
type: "bundle",
input: "./src/index.ts",
input: ["./src/index.ts", "./src/osc/index.ts"],
},
],
// hooks: {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
],
"type": "module",
"exports": {
".": "./dist/index.mjs"
".": "./dist/index.mjs",
"./osc": "./dist/osc/index.mjs"
},
"scripts": {
"build": "zig build --release && obuild",
Expand Down
74 changes: 74 additions & 0 deletions scripts/osc-demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env bun
/**
* OSC / escape-sequence demo — emits OS attention & notification signals.
*
* Cross-platform replacement for osc-demo.sh. Run directly via bun or via
* node ≥22.6 with --experimental-strip-types. The output is identical to the
* bash version so the OSC inspector can parse the same sequences anywhere.
*
* bun scripts/osc-demo.ts
* DELAY=0.05 bun scripts/osc-demo.ts
*/
import * as os from "node:os";

const DELAY = Number(process.env.DELAY ?? "1");
const sleep = (s: number) => new Promise<void>((r) => setTimeout(r, s * 1000));
const out = (s: string) => process.stdout.write(s);

out("=== Window Title ===\n");
out("\x1b]0;zigpty demo — window title\x07");
out("\x1b]2;zigpty demo — title only\x07");
out("\x1b]1;zigpty-tab\x07");

out("=== Current Working Directory (OSC 7) ===\n");
out(`\x1b]7;file://${os.hostname()}${process.cwd()}\x07`);

out("=== Shell Integration Marks (OSC 133) ===\n");
out("\x1b]133;A\x07");
out("\x1b]133;B\x07");
out("\x1b]133;C\x07");
out("\x1b]133;D;0\x07");

out("=== VS Code Shell Integration (OSC 633) ===\n");
out("\x1b]633;A\x07");
out("\x1b]633;B\x07");
out("\x1b]633;E;echo hello\x07");
out("\x1b]633;C\x07");
out("hello\n");
out("\x1b]633;D;0\x07");
out(`\x1b]633;P;Cwd=${process.cwd()}\x07`);

out("=== BEL (Bell) ===\n");
out("\x07");

out("=== Urgent / Window Attention ===\n");
out("\x1b]1337;RequestAttention=yes\x07");
out("\x1b]1337;RequestAttention=fireworks\x07");
out("\x1b]777;urgency;push\x1b\\");

out("=== Desktop Notifications ===\n");
out("\x1b]9;Build finished\x07");
out("\x1b]99;i=1:p=title;Build Status\x1b\\");
out("\x1b]99;i=1:p=body;All tests passed\x1b\\");
out("\x1b]99;i=1:p=done\x1b\\");
out("\x1b]777;notify;Build;Tests passed\x1b\\");
out("\x1b]1337;notify;title=Build;body=Tests passed\x07");
if (process.env.TMUX) {
out("\x1bPtmux;\x1b\x1b]9;Build finished (via tmux)\x07\x1b\\");
}

out("=== Progress Indicators ===\n");
out("\x1b]9;4;3;0\x1b\\");
await sleep(0.5);
for (const pct of [0, 10, 25, 50, 75, 90, 100]) {
out(`\x1b]9;4;1;${pct}\x1b\\`);
await sleep(0.3);
}
out("\x1b]9;4;2;100\x1b\\");
await sleep(0.5);
out("\x1b]9;4;4;75\x1b\\");
await sleep(0.5);
out("\x1b]9;4;0;0\x1b\\");
await sleep(DELAY);

out("=== Done ===\n");
Loading
Loading