From 5d685538ba256e933d659416f26f0980f9dbbbf0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 2 Jun 2026 22:05:48 +0200 Subject: [PATCH] fix: default PROMPT_EOL_MARK= in spawned shells Spawned shells now default PROMPT_EOL_MARK to empty so zsh's standout "%" end-of-partial-line marker no longer leaks into snapshots, screenshots, and recordings. agent-tty strips a hidden per-run completion-marker postamble, which desynced the rendered cursor from the shell's and left the "%" visible. The marker is zsh-only and inert in other shells. The default is applied at PTY spawn time in resolvePtyEnv and is not written to the manifest, so inspect/list/create --json env maps are unchanged. An explicit `--env PROMPT_EOL_MARK=...` always wins (use '%B%S%#%s%b' to restore zsh's styled default; a lone '%' expands to nothing). Change-Id: Ia6f199bb33e52baff73a008a39c74500e5c5e050 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- CHANGELOG.md | 1 + docs/TROUBLESHOOTING.md | 11 ++++++ docs/USAGE.md | 13 +++++++ src/pty/createPty.ts | 50 +++++++++++++++++++++++--- test/integration/lifecycle.test.ts | 47 ++++++++++++++++++++++++ test/unit/pty/createPty.test.ts | 57 ++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 test/unit/pty/createPty.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ee0354c4..31e580e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ## Changed +- Spawned shells now default `PROMPT_EOL_MARK=` (empty) in the session environment, suppressing the inverse-video `%` end-of-partial-line marker that `zsh` prints when output lacks a trailing newline. agent-tty strips a hidden completion-marker postamble after each `run`, which desynced the rendered cursor and left that `%` in snapshots, screenshots, and recordings; the default keeps captures clean. The marker is zsh-only and inert in other shells. Opt back in per session with `agent-tty create --env PROMPT_EOL_MARK='%B%S%#%s%b' -- ` to restore zsh's styled default (a lone `'%'` expands to nothing), or pass any explicit `--env PROMPT_EOL_MARK=...` value. The default is applied at PTY spawn time and is not written to the manifest, so `inspect`, `list`, and `create --json` env maps are unchanged ([#114](https://github.com/coder/agent-tty/pull/114)). - `inspect` collects renderer state and the session snapshot in a single synchronous tick before awaiting, so concurrent RPC handlers cannot interleave a mutated renderer state with a stale session snapshot ([#104](https://github.com/coder/agent-tty/pull/104)). - `mise` development toolchain refresh; ordinary CI remains credential-free and does not install the live-demo recorder tools ([#107](https://github.com/coder/agent-tty/pull/107)). diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 332c0b93..11aa6ea0 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -81,3 +81,14 @@ or install a GitHub Release tarball as described in [`INSTALL.md`](./INSTALL.md) It gives repeatable artifacts for review and automation, but it does not guarantee exact native-terminal pixel parity. If a bug depends on a specific native terminal emulator, keep the `agent-tty` artifact as reference evidence and capture native-terminal evidence separately when needed. + +## Stray `%` at the End of Captured Output + +If a snapshot, screenshot, or recording shows an unexpected inverse-video `%` at the end of output that has no trailing newline, that is `zsh`'s `PROMPT_EOL_MARK` end-of-partial-line indicator. agent-tty spawns shells with `PROMPT_EOL_MARK=` (empty) by default to suppress it, so you should normally not see it. + +If it still appears: + +- A `PROMPT_EOL_MARK` assignment in your `~/.zshrc` overrides the default (rc files load after the environment is imported). Remove that line, or set the value you want explicitly with `agent-tty create --env PROMPT_EOL_MARK=... -- `. +- The program running inside the session set the marker itself. + +To deliberately keep the marker, pass `--env PROMPT_EOL_MARK='%B%S%#%s%b'` (zsh's styled default) when creating the session. diff --git a/docs/USAGE.md b/docs/USAGE.md index 184e224a..519e6625 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -132,6 +132,19 @@ agent-tty --home "$AGENT_HOME" doctor --json Avoid writing automated sessions into the default `~/.agent-tty` unless you intentionally want shared local state. +## Shell Environment + +`create` spawns the shell with your inherited environment plus `TERM` (from `--term`) and a default `PROMPT_EOL_MARK=` (empty). The empty `PROMPT_EOL_MARK` suppresses the inverse-video `%` that `zsh` prints at the end of any output without a trailing newline; without it, agent-tty's hidden per-`run` completion marker leaves a stray `%` in snapshots, screenshots, and recordings. The variable is zsh-only and inert in other shells. + +Any `--env` value always wins, so you can opt back into the shell's native behavior per session: + +```bash +# Restore zsh's styled default marker: +agent-tty create --env PROMPT_EOL_MARK='%B%S%#%s%b' -- /bin/zsh +``` + +A lone `'%'` does **not** restore the marker (zsh treats it as a prompt escape that expands to nothing); use `'%B%S%#%s%b'` for the styled default or `'%%'` for a plain percent. The default is applied at spawn time and is not stored in the manifest, so it does not appear in `inspect`, `list`, or `create --json` output. If your `~/.zshrc` assigns `PROMPT_EOL_MARK` it runs after the environment is imported and wins, so the marker can reappear — remove that line or set the value you want via `--env`. + ## Anti-Patterns - Do not reach for `tmux`, `screen`, or ad hoc PTY wrappers first when `agent-tty` can provide an isolated, inspectable session. diff --git a/src/pty/createPty.ts b/src/pty/createPty.ts index e3cd44e8..2e3f0b11 100644 --- a/src/pty/createPty.ts +++ b/src/pty/createPty.ts @@ -67,6 +67,50 @@ function ensureDarwinSpawnHelperExecutable(): void { } } +/** + * The zsh-only `PROMPT_EOL_MARK` parameter controls the inverse-video glyph zsh + * prints when a line of output has no trailing newline (its default expands to a + * bold standout `%`). agent-tty injects a hidden completion-marker postamble + * after each `run` and strips that postamble's echo from the output stream; the + * strip leaves the rendered cursor mid-line, so zsh's unconditional end-of-line + * mark surfaces as a stray `%` in snapshots, screenshots, and recordings. + * Defaulting the parameter to empty suppresses the glyph. It is inert in shells + * that do not implement it (bash, etc.), so setting it unconditionally is safe. + */ +const PROMPT_EOL_MARK_ENV_KEY = 'PROMPT_EOL_MARK'; + +/** + * Resolves the environment handed to the spawned PTY shell. + * + * Precedence, lowest to highest: the inherited process environment, then the + * `PROMPT_EOL_MARK=''` default, then the caller-supplied `env` (so a `--env` + * value always wins — even an explicit empty one), then `TERM`. The default sits + * after the inherited environment so it also overrides any inherited + * `PROMPT_EOL_MARK`, keeping captures deterministic regardless of the launching + * shell. The presence check is against `env` (the user-explicit set) rather than + * the merged result, so an inherited value never counts as opting out. + */ +export function resolvePtyEnv( + env: Record, + term: string, + baseEnv: Record = process.env, +): Record { + const resolved: Record = {}; + for (const [key, value] of Object.entries(baseEnv)) { + if (value !== undefined) { + resolved[key] = value; + } + } + + if (!Object.prototype.hasOwnProperty.call(env, PROMPT_EOL_MARK_ENV_KEY)) { + resolved[PROMPT_EOL_MARK_ENV_KEY] = ''; + } + + Object.assign(resolved, env); + resolved.TERM = term; + return resolved; +} + export function createPty(options: PtyOptions): IPty { const { command, cwd, cols, rows, env, term } = options; @@ -95,10 +139,6 @@ export function createPty(options: PtyOptions): IPty { cwd, cols, rows, - env: { - ...process.env, - ...env, - TERM: term, - }, + env: resolvePtyEnv(env, term), }); } diff --git a/test/integration/lifecycle.test.ts b/test/integration/lifecycle.test.ts index ed1ffa39..c1632746 100644 --- a/test/integration/lifecycle.test.ts +++ b/test/integration/lifecycle.test.ts @@ -283,6 +283,53 @@ describe('lifecycle integration', { timeout: 30000 }, () => { expect(allOutput).toContain('bar|qux|vt100'); }); + it('defaults PROMPT_EOL_MARK to empty in the spawned shell, overriding an inherited value', async () => { + // The default is injected at PTY spawn time so it never appears in the + // manifest; assert it by reading it back from inside the spawned shell. + // Export a sentinel PROMPT_EOL_MARK in the parent env so this discriminates + // the fix: only an active override yields an empty value in the child — + // without it the shell would inherit the sentinel. (`--env` precedence is + // covered by the resolvePtyEnv unit test; that `--env` reaches the real + // shell is covered by the manifest/env test above.) + const createResult = runCli( + [ + 'create', + '--json', + '--', + '/bin/sh', + '-c', + 'printf "[%s]" "$PROMPT_EOL_MARK"; exit 0', + ], + { AGENT_TTY_HOME: testHome, PROMPT_EOL_MARK: 'inherited-sentinel' }, + ); + expect(createResult.status).toBe(0); + expect(createResult.stderr).toBe(''); + const sessionId = ( + JSON.parse(createResult.stdout) as SuccessEnvelope<{ sessionId: string }> + ).result.sessionId; + + await new Promise((resolve) => { + setTimeout(resolve, 2000); + }); + + const eventContent = await readFile( + join(testHome, 'sessions', sessionId, 'events.jsonl'), + 'utf8', + ); + const allOutput = eventContent + .trim() + .split('\n') + .map((line) => JSON.parse(line) as EventRecord) + .filter((event) => event.type === 'output') + .map((event) => { + const data = event.payload.data; + return typeof data === 'string' ? data : ''; + }) + .join(''); + expect(allOutput).toContain('[]'); + expect(allOutput).not.toContain('inherited-sentinel'); + }); + it('persists a provided idle timeout in the session manifest', async () => { const createResult = runCli( [ diff --git a/test/unit/pty/createPty.test.ts b/test/unit/pty/createPty.test.ts new file mode 100644 index 00000000..51c8b535 --- /dev/null +++ b/test/unit/pty/createPty.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { resolvePtyEnv } from '../../../src/pty/createPty.js'; + +describe('resolvePtyEnv', () => { + it('defaults PROMPT_EOL_MARK to empty when the caller does not set it', () => { + const resolved = resolvePtyEnv({}, 'xterm-256color', {}); + + expect(resolved.PROMPT_EOL_MARK).toBe(''); + }); + + it('lets a caller-supplied PROMPT_EOL_MARK win, including an explicit empty one', () => { + expect( + resolvePtyEnv({ PROMPT_EOL_MARK: '%' }, 'xterm-256color', {}) + .PROMPT_EOL_MARK, + ).toBe('%'); + expect( + resolvePtyEnv({ PROMPT_EOL_MARK: '' }, 'xterm-256color', {}) + .PROMPT_EOL_MARK, + ).toBe(''); + }); + + it('overrides an inherited PROMPT_EOL_MARK when the caller does not set it', () => { + const resolved = resolvePtyEnv({}, 'xterm-256color', { + PROMPT_EOL_MARK: '%', + }); + + expect(resolved.PROMPT_EOL_MARK).toBe(''); + }); + + it('keeps an inherited PROMPT_EOL_MARK only when the caller re-supplies it', () => { + const resolved = resolvePtyEnv({ PROMPT_EOL_MARK: '%B%S%#%s%b' }, 'vt100', { + PROMPT_EOL_MARK: 'stale', + }); + + expect(resolved.PROMPT_EOL_MARK).toBe('%B%S%#%s%b'); + }); + + it('always forces TERM to the provided value over inherited and caller env', () => { + const resolved = resolvePtyEnv({ TERM: 'caller' }, 'vt100', { + TERM: 'inherited', + }); + + expect(resolved.TERM).toBe('vt100'); + }); + + it('passes through inherited and caller env entries and drops undefined values', () => { + const resolved = resolvePtyEnv({ FOO: 'bar' }, 'xterm-256color', { + BAZ: 'qux', + EMPTY: undefined, + }); + + expect(resolved.FOO).toBe('bar'); + expect(resolved.BAZ).toBe('qux'); + expect(Object.prototype.hasOwnProperty.call(resolved, 'EMPTY')).toBe(false); + }); +});