diff --git a/src/react/JabTerm.tsx b/src/react/JabTerm.tsx index 69d1a4e..a4be74a 100644 --- a/src/react/JabTerm.tsx +++ b/src/react/JabTerm.tsx @@ -3,7 +3,13 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; import * as Xterm from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; -import type { JabTermHandle, JabTermProps, JabTermState } from "./types.js"; +import type { + JabTermHandle, + JabTermProps, + JabTermState, + WriteAndWaitOptions, + WriteAndWaitResult, +} from "./types.js"; import type { Terminal as XtermTerminal } from "@xterm/xterm"; const DEFAULT_FONT_FAMILY = @@ -18,6 +24,9 @@ const JabTerm = forwardRef(function JabTerm( onOpen, onClose, onError, + onData, + onExit, + onCommandEnd, captureOutput = true, maxCaptureChars = 200_000, className, @@ -44,6 +53,10 @@ const JabTerm = forwardRef(function JabTerm( const captureReadOffsetRef = useRef(0); const decoderRef = useRef(null); + const lastExitCodeRef = useRef(null); + const dataListenersRef = useRef void>>(new Set()); + const commandEndListenersRef = useRef void>>(new Set()); + captureEnabledRef.current = captureOutput; captureMaxCharsRef.current = maxCaptureChars; @@ -165,6 +178,139 @@ const JabTerm = forwardRef(function JabTerm( const start = Math.min(Math.max(captureReadOffsetRef.current, 0), s.length); return s.length - start; }, + getLastExitCode() { + return lastExitCodeRef.current; + }, + waitForCommandEnd(timeoutMs?: number) { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("WebSocket is not open")); + } + const timeout = Math.max(Number(timeoutMs ?? 30_000) || 0, 0); + return new Promise((resolve, reject) => { + let done = false; + let timer: ReturnType | null = null; + + const listener = (exitCode: number) => { + if (done) return; + done = true; + if (timer) clearTimeout(timer); + commandEndListenersRef.current.delete(listener); + resolve(exitCode); + }; + + commandEndListenersRef.current.add(listener); + if (timeout > 0) { + timer = setTimeout(() => { + if (done) return; + done = true; + commandEndListenersRef.current.delete(listener); + reject(new Error(`Timeout waiting for commandEnd (${timeout}ms)`)); + }, timeout); + } + }); + }, + async writeAndWait( + input: string | Uint8Array | ArrayBuffer, + options?: WriteAndWaitOptions, + ): Promise { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error("WebSocket is not open"); + } + if (disposedRef.current) { + throw new Error("Terminal is disposed"); + } + + const opts = options ?? {}; + const quietMs = Math.max(Number(opts.quietMs ?? 300) || 0, 0); + const timeoutMs = Math.max(Number(opts.timeout ?? 30_000) || 0, 0); + const waitFor = typeof opts.waitFor === "string" && opts.waitFor ? opts.waitFor : null; + const waitForCommand = !!opts.waitForCommand; + + return await new Promise((resolve, reject) => { + let done = false; + let output = ""; + let exitCode: number | undefined = undefined; + let quietTimer: ReturnType | null = null; + let timeoutTimer: ReturnType | null = null; + + const cleanup = () => { + if (quietTimer) clearTimeout(quietTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + dataListenersRef.current.delete(onChunk); + commandEndListenersRef.current.delete(onCommandEndEvent); + }; + + const finishOk = () => { + if (done) return; + done = true; + cleanup(); + resolve({ output, ...(exitCode !== undefined ? { exitCode } : {}) }); + }; + + const finishErr = (err: unknown) => { + if (done) return; + done = true; + cleanup(); + reject(err instanceof Error ? err : new Error(String(err))); + }; + + const armQuiet = () => { + if (waitFor || waitForCommand) return; + if (quietMs <= 0) { + finishOk(); + return; + } + if (quietTimer) clearTimeout(quietTimer); + quietTimer = setTimeout(() => finishOk(), quietMs); + }; + + const onChunk = (chunk: string) => { + output += chunk; + if (waitFor && output.includes(waitFor)) { + finishOk(); + return; + } + armQuiet(); + }; + + const onCommandEndEvent = (code: number) => { + exitCode = code; + if (!waitForCommand) return; + finishOk(); + }; + + dataListenersRef.current.add(onChunk); + commandEndListenersRef.current.add(onCommandEndEvent); + + if (timeoutMs > 0) { + timeoutTimer = setTimeout(() => { + finishErr(new Error(`Timeout in writeAndWait (${timeoutMs}ms)`)); + }, timeoutMs); + } + + if (disposedRef.current) { + finishErr(new Error("Terminal disposed")); + return; + } + + try { + if (typeof input === "string") { + ws.send(new TextEncoder().encode(input)); + } else if (input instanceof ArrayBuffer) { + ws.send(input); + } else { + ws.send(input); + } + } catch (err) { + finishErr(err); + return; + } + + armQuiet(); + }); + }, }), [], ); @@ -241,18 +387,42 @@ const JabTerm = forwardRef(function JabTerm( appendCapture(`\nError: ${msg}\n`); return; } + if (parsed?.type === "ptyExit") { + const exitCode = Number(parsed.exitCode); + const signalRaw = parsed.signal; + const signal = + signalRaw === null || signalRaw === undefined ? null : Number(signalRaw); + if (Number.isFinite(exitCode)) { + onExit?.(exitCode, Number.isFinite(signal) ? signal : null); + } + return; + } + if (parsed?.type === "commandEnd") { + const exitCode = Number(parsed.exitCode); + if (Number.isFinite(exitCode)) { + lastExitCodeRef.current = exitCode; + onCommandEnd?.(exitCode); + for (const cb of commandEndListenersRef.current) cb(exitCode); + } + return; + } } catch { /* ignore */ } } term.write(event.data); appendCapture(event.data); + onData?.(event.data); + for (const cb of dataListenersRef.current) cb(event.data); } else { const bytes = new Uint8Array(event.data as ArrayBuffer); term.write(bytes); try { if (!decoderRef.current) decoderRef.current = new TextDecoder(); - appendCapture(decoderRef.current.decode(bytes, { stream: true })); + const decoded = decoderRef.current.decode(bytes, { stream: true }); + appendCapture(decoded); + onData?.(decoded); + for (const cb of dataListenersRef.current) cb(decoded); } catch { /* ignore */ } diff --git a/src/react/types.ts b/src/react/types.ts index ac761a7..5e481ae 100644 --- a/src/react/types.ts +++ b/src/react/types.ts @@ -2,6 +2,27 @@ import type { Terminal } from "@xterm/xterm"; export type JabTermState = "connecting" | "open" | "closed"; +export interface WriteAndWaitOptions { + /** + * Resolve after this many ms of silence (no new output). + * Used only when neither `waitFor` nor `waitForCommand` is set. + * + * Default: 300 + */ + quietMs?: number; + /** Overall timeout in ms. Default: 30_000 */ + timeout?: number; + /** Resolve once this substring appears in captured output. */ + waitFor?: string; + /** Resolve on the next `commandEnd` event (requires server shell integration). */ + waitForCommand?: boolean; +} + +export interface WriteAndWaitResult { + output: string; + exitCode?: number; +} + export interface JabTermHandle { focus(): void; fit(): void; @@ -17,6 +38,19 @@ export interface JabTermHandle { readNew(): string; /** Returns the character count of unread output since last readAll/readNew. */ getNewCount(): number; + /** Returns the last command exit code observed via shell integration. */ + getLastExitCode(): number | null; + /** Resolves with the next command exit code observed via shell integration. */ + waitForCommandEnd(timeoutMs?: number): Promise; + /** + * Send input and wait for completion conditions. + * + * The returned `output` contains only data received after the call begins. + */ + writeAndWait( + input: string | Uint8Array | ArrayBuffer, + options?: WriteAndWaitOptions, + ): Promise; } export interface JabTermProps { @@ -30,6 +64,12 @@ export interface JabTermProps { onClose?: (ev: CloseEvent) => void; /** Fires on WebSocket errors. */ onError?: (ev: Event) => void; + /** Fires for each chunk of output received from the server. */ + onData?: (data: string) => void; + /** Fires when the underlying PTY exits (before the WebSocket closes). */ + onExit?: (exitCode: number, signal: number | null) => void; + /** Fires when shell integration reports a command exit code. */ + onCommandEnd?: (exitCode: number) => void; /** * Capture terminal output into an internal buffer so imperative `read*()` * methods can be used for testing/automation. diff --git a/src/server/jabtermServer.ts b/src/server/jabtermServer.ts index 2b23b23..9fb41b8 100644 --- a/src/server/jabtermServer.ts +++ b/src/server/jabtermServer.ts @@ -1,6 +1,8 @@ import http, { type IncomingMessage } from "http"; import type { Duplex } from "stream"; import os from "os"; +import fs from "fs"; +import path from "path"; import * as pty from "node-pty"; import { WebSocketServer, WebSocket } from "ws"; import { @@ -71,6 +73,18 @@ export interface JabtermServerOptions { | ((origin: string | undefined, req: IncomingMessage) => boolean); /** Optional structured logger. */ logger?: JabtermLogger; + /** + * If true, enables best-effort shell integration for richer automation signals. + * + * Currently implemented for: + * - bash: injects a `PROMPT_COMMAND` hook + * - zsh: injects a `precmd` hook via a temporary `ZDOTDIR` `.zshrc` + * + * Both emit an OSC marker which the server converts into structured WS messages. + * + * Default: false + */ + shellIntegration?: boolean; /** * Optional hook invoked on each connection to configure the PTY based on * terminalId and/or request metadata. @@ -105,10 +119,13 @@ interface Session { wsClosed: boolean; helloVersion?: number; onWsMessage: (message: Buffer | string) => void; + oscRemainder: string; } const JABTERM_PROTOCOL_VERSION = 1; +const JABTERM_COMMAND_END_OSC_PREFIX = "\x1b]633;D;"; + function defaultLogger(): JabtermLogger { return { debug: (message, meta) => console.debug(`[jabterm] ${message}`, meta ?? ""), @@ -118,6 +135,138 @@ function defaultLogger(): JabtermLogger { }; } +function isBashShell(shellPath: string): boolean { + const base = shellPath.split(/[\\/]/).pop()?.toLowerCase() ?? shellPath.toLowerCase(); + return base === "bash" || base.endsWith("bash"); +} + +function isZshShell(shellPath: string): boolean { + const base = shellPath.split(/[\\/]/).pop()?.toLowerCase() ?? shellPath.toLowerCase(); + return base === "zsh" || base.endsWith("zsh"); +} + +function shSingleQuote(s: string): string { + // POSIX-ish single-quote escaping: ' -> '\''. + return `'${s.replace(/'/g, `'\\''`)}'`; +} + +function applyShellIntegration( + enabled: boolean, + shell: string, + env: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + if (!enabled) return env; + + if (isBashShell(shell)) { + const existing = typeof env.PROMPT_COMMAND === "string" ? env.PROMPT_COMMAND : ""; + if (existing.includes("]633;D;")) return { ...env, JABTERM_SHELL_INTEGRATION: "1" }; + + // IMPORTANT: do not redefine a function inside PROMPT_COMMAND on every prompt, + // otherwise `$?` inside the function becomes the status of the function + // definition (usually 0) instead of the user's last command. + const hook = "printf '\\033]633;D;%s\\007' \"$?\""; + const merged = existing.trim() ? `${hook}; ${existing}` : hook; + + return { + ...env, + JABTERM_SHELL_INTEGRATION: "1", + PROMPT_COMMAND: merged, + }; + } + + if (isZshShell(shell)) { + // zsh doesn't support reliable hook injection via env vars alone. + // Use a temporary ZDOTDIR with a generated .zshrc that sources the user's + // original rc and appends a precmd hook. + const origZdotdir = typeof env.ZDOTDIR === "string" && env.ZDOTDIR ? env.ZDOTDIR : null; + const home = typeof env.HOME === "string" && env.HOME ? env.HOME : null; + const origRcPath = origZdotdir + ? path.join(origZdotdir, ".zshrc") + : home + ? path.join(home, ".zshrc") + : null; + + let zdotdir: string; + try { + zdotdir = fs.mkdtempSync(path.join(os.tmpdir(), "jabterm-zsh-")); + } catch { + return env; + } + + const sourceOriginal = origRcPath + ? `[[ -f ${shSingleQuote(origRcPath)} ]] && source ${shSingleQuote(origRcPath)}\n` + : ""; + + const zshrc = `# Generated by jabterm (shellIntegration) +${sourceOriginal}__jabterm_precmd() { printf '\\033]633;D;%s\\007' "$?"; } +typeset -ga precmd_functions +precmd_functions=(__jabterm_precmd $precmd_functions) +`; + + try { + fs.writeFileSync(path.join(zdotdir, ".zshrc"), zshrc, { encoding: "utf8", mode: 0o600 }); + } catch { + return env; + } + + return { + ...env, + JABTERM_SHELL_INTEGRATION: "1", + ZDOTDIR: zdotdir, + }; + } + + return env; +} + +function extractCommandEndEvents( + input: string, + remainder: string, +): { clean: string; remainder: string; exitCodes: number[] } { + const data = remainder + input; + const exitCodes: number[] = []; + const outParts: string[] = []; + + let i = 0; + while (i < data.length) { + const start = data.indexOf(JABTERM_COMMAND_END_OSC_PREFIX, i); + if (start === -1) { + outParts.push(data.slice(i)); + return { clean: outParts.join(""), remainder: "", exitCodes }; + } + + outParts.push(data.slice(i, start)); + + const payloadStart = start + JABTERM_COMMAND_END_OSC_PREFIX.length; + const bel = data.indexOf("\x07", payloadStart); + const st = data.indexOf("\x1b\\", payloadStart); + + let end = -1; + let endLen = 0; + if (bel !== -1 && (st === -1 || bel < st)) { + end = bel; + endLen = 1; + } else if (st !== -1) { + end = st; + endLen = 2; + } + + if (end === -1) { + // Incomplete OSC sequence; keep it for the next chunk. + const rem = data.slice(start); + return { clean: outParts.join(""), remainder: rem, exitCodes }; + } + + const payload = data.slice(payloadStart, end).trim(); + const code = Number.parseInt(payload, 10); + if (Number.isFinite(code)) exitCodes.push(code); + + i = end + endLen; + } + + return { clean: outParts.join(""), remainder: "", exitCodes }; +} + function normalizeBasePath(p: string | undefined): string { const raw = (p ?? "/").trim() || "/"; if (raw === "/") return "/"; @@ -153,6 +302,7 @@ export function createJabtermServer(opts: JabtermServerOptions = {}): JabtermSer const port = opts.port ?? 3223; const strictPort = opts.strictPort ?? false; const logger = opts.logger ?? defaultLogger(); + const shellIntegration = opts.shellIntegration ?? false; const httpServer = http.createServer((req, res) => { res.statusCode = 404; @@ -255,7 +405,8 @@ export function createJabtermServer(opts: JabtermServerOptions = {}): JabtermSer const cwd = extra.cwd ?? defaultCwd; const cols = Math.max(extra.cols ?? 80, 10); const rows = Math.max(extra.rows ?? 24, 10); - const ptyEnv = { ...env, ...(extra.env ?? {}) }; + const ptyEnvRaw = { ...env, ...(extra.env ?? {}) }; + const ptyEnv = applyShellIntegration(shellIntegration, shell, ptyEnvRaw); const args = extra.shellArgs ?? []; return pty.spawn(shell, args, { @@ -310,6 +461,7 @@ export function createJabtermServer(opts: JabtermServerOptions = {}): JabtermSer wsClosed: false, helloVersion: undefined, onWsMessage: () => { }, + oscRemainder: "", }; sessions.add(session); @@ -323,9 +475,15 @@ export function createJabtermServer(opts: JabtermServerOptions = {}): JabtermSer }; ptyProcess.onData((data: string) => { - if (ws.readyState === WebSocket.OPEN) { + if (ws.readyState !== WebSocket.OPEN) return; + const { clean, remainder, exitCodes } = shellIntegration + ? extractCommandEndEvents(data, session.oscRemainder) + : { clean: data, remainder: "", exitCodes: [] as number[] }; + session.oscRemainder = remainder; + + if (clean) { try { - ws.send(data); + ws.send(clean); } catch (err) { logger.warn?.("ws_send_failed", { terminalId, @@ -333,6 +491,16 @@ export function createJabtermServer(opts: JabtermServerOptions = {}): JabtermSer }); } } + + if (exitCodes.length > 0) { + for (const exitCode of exitCodes) { + try { + ws.send(JSON.stringify({ type: "commandEnd", exitCode })); + } catch { + /* ignore */ + } + } + } }); const onWsMessage = (message: Buffer | string) => { @@ -440,6 +608,19 @@ export function createJabtermServer(opts: JabtermServerOptions = {}): JabtermSer } catch { /* ignore */ } + if (!session.wsClosed && ws.readyState === WebSocket.OPEN) { + try { + ws.send( + JSON.stringify({ + type: "ptyExit", + exitCode, + signal: signal ?? null, + }), + ); + } catch { + /* ignore */ + } + } if (!session.wsClosed && ws.readyState === WebSocket.OPEN) { if (exitCode !== 0) { sendError(`PTY exited with code ${exitCode}`); diff --git a/tests/server-api.e2e.ts b/tests/server-api.e2e.ts index 6462f2e..f04afe8 100644 --- a/tests/server-api.e2e.ts +++ b/tests/server-api.e2e.ts @@ -239,5 +239,87 @@ test.describe("Server API — createJabtermServer", () => { expect(result).toBe("error"); }); + + test("sends ptyExit message before websocket close", async () => { + const server = createJabtermServer({ + host: "127.0.0.1", + port: 0, + path: "/ws", + }); + const addr = await server.listen(); + + const ws = new WsClient(wsUrl(addr.port, "/ws/exit")); + await waitForWsOpen(ws); + await waitForMatch(ws, /.+/s, 4000); + + ws.send(Buffer.from("exit\n")); + const raw = await waitForMatch(ws, /"type"\s*:\s*"ptyExit"/, 8000); + expect(raw).toContain("\"ptyExit\""); + + await new Promise((resolve) => ws.once("close", resolve)); + await server.close(); + }); + + test("shellIntegration emits commandEnd with exit code (bash best-effort)", async () => { + const server = createJabtermServer({ + host: "127.0.0.1", + port: 0, + path: "/ws", + shellIntegration: true, + }); + const addr = await server.listen(); + + const ws = new WsClient(wsUrl(addr.port, "/ws/cmdend")); + await waitForWsOpen(ws); + await waitForMatch(ws, /.+/s, 4000); + + ws.send(Buffer.from("false\n")); + const raw = await waitForMatch( + ws, + /"type"\s*:\s*"commandEnd"[\s\S]*"exitCode"\s*:\s*1/, + 8000, + ); + expect(raw).toContain("\"commandEnd\""); + + ws.close(); + await server.close(); + }); + + test("shellIntegration emits commandEnd with exit code under zsh (precmd best-effort)", async () => { + const { execSync } = await import("child_process"); + let zshPath: string | null = null; + try { + zshPath = execSync("command -v zsh", { stdio: ["ignore", "pipe", "ignore"] }) + .toString("utf8") + .trim(); + } catch { + zshPath = null; + } + test.skip(!zshPath, "zsh is not installed"); + + const server = createJabtermServer({ + host: "127.0.0.1", + port: 0, + path: "/ws", + shell: zshPath!, + shellIntegration: true, + }); + const addr = await server.listen(); + + const ws = new WsClient(wsUrl(addr.port, "/ws/cmdend-zsh")); + await waitForWsOpen(ws); + await waitForMatch(ws, /.+/s, 4000); + + ws.send(Buffer.from("false\n")); + const raw = await waitForMatch( + ws, + /"type"\s*:\s*"commandEnd"[\s\S]*"exitCode"\s*:\s*1/, + 8000, + ); + expect(raw).toContain("\"commandEnd\""); + + ws.close(); + await server.close(); + }); }); diff --git a/tests/unit/react/JabTerm.test.tsx b/tests/unit/react/JabTerm.test.tsx index 0dfd683..9f7655f 100644 --- a/tests/unit/react/JabTerm.test.tsx +++ b/tests/unit/react/JabTerm.test.tsx @@ -106,6 +106,120 @@ describe("", () => { expect(ref.current!.readNew()).toBe(""); }); + it("calls onData for incoming output", async () => { + const onData = vi.fn(); + render(); + + const ws = await waitFor(() => { + const cur = getMockWs(); + expect(cur).toBeTruthy(); + return cur; + }); + act(() => ws.__open()); + + act(() => ws.__message("hello\n")); + expect(onData).toHaveBeenCalledWith("hello\n"); + }); + + it("handles commandEnd messages (callback + handle helpers) without writing JSON to terminal", async () => { + const ref = createRef(); + const onCommandEnd = vi.fn(); + render(); + + const ws = await waitFor(() => { + const cur = getMockWs(); + expect(cur).toBeTruthy(); + return cur; + }); + act(() => ws.__open()); + + expect(ref.current!.getLastExitCode()).toBeNull(); + const beforeWrites = lastTerminal.write.mock.calls.length; + + const p = ref.current!.waitForCommandEnd(1000); + act(() => ws.__message(JSON.stringify({ type: "commandEnd", exitCode: 7 }))); + + await expect(p).resolves.toBe(7); + expect(ref.current!.getLastExitCode()).toBe(7); + expect(onCommandEnd).toHaveBeenCalledWith(7); + expect(lastTerminal.write.mock.calls.length).toBe(beforeWrites); + expect(ref.current!.readNew()).toBe(""); + }); + + it("handles ptyExit messages via onExit without writing JSON to terminal", async () => { + const onExit = vi.fn(); + render(); + + const ws = await waitFor(() => { + const cur = getMockWs(); + expect(cur).toBeTruthy(); + return cur; + }); + act(() => ws.__open()); + + const beforeWrites = lastTerminal.write.mock.calls.length; + act(() => ws.__message(JSON.stringify({ type: "ptyExit", exitCode: 0, signal: null }))); + + expect(onExit).toHaveBeenCalledWith(0, null); + expect(lastTerminal.write.mock.calls.length).toBe(beforeWrites); + }); + + it("writeAndWait resolves on quiet output stabilization", async () => { + vi.useFakeTimers(); + try { + const ref = createRef(); + render(); + const ws = getMockWs(); + act(() => ws.__open()); + + const p = ref.current!.writeAndWait("echo hi\n", { quietMs: 50, timeout: 1000 }); + act(() => ws.__message("hi\n")); + + await act(async () => { + vi.advanceTimersByTime(60); + await Promise.resolve(); + }); + + await expect(p).resolves.toEqual({ output: "hi\n" }); + } finally { + vi.useRealTimers(); + } + }); + + it("writeAndWait resolves when waitFor marker appears", async () => { + const ref = createRef(); + render(); + const ws = await waitFor(() => { + const cur = getMockWs(); + expect(cur).toBeTruthy(); + return cur; + }); + act(() => ws.__open()); + + const p = ref.current!.writeAndWait("noop\n", { waitFor: "READY", timeout: 1000 }); + act(() => ws.__message("not yet\n")); + act(() => ws.__message("READY\n")); + + await expect(p).resolves.toEqual({ output: "not yet\nREADY\n" }); + }); + + it("writeAndWait can resolve on commandEnd and return exitCode", async () => { + const ref = createRef(); + render(); + const ws = await waitFor(() => { + const cur = getMockWs(); + expect(cur).toBeTruthy(); + return cur; + }); + act(() => ws.__open()); + + const p = ref.current!.writeAndWait("false\n", { waitForCommand: true, timeout: 1000 }); + act(() => ws.__message("running...\n")); + act(() => ws.__message(JSON.stringify({ type: "commandEnd", exitCode: 1 }))); + + await expect(p).resolves.toEqual({ output: "running...\n", exitCode: 1 }); + }); + it("clamps resize and sends resize message when open", async () => { const ref = createRef(); render();