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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
# Changelog

## 0.6.23 - 2026-07-23

### Fixed
- **`-q/--quiet` and the non-TTY auto-quiet now apply when the message arrives on piped
stdin (gh #93).** `-q` is documented to suppress the header, spinner, tool chatter,
timing, and color and emit only the agent's reply, and #53 wired the same auto-quiet
for a non-TTY stdout so a piped run is clean without any flag. That contract held for a
single-shot run — a `MESSAGE` arg or `-f/--file` — but not for the most idiomatic pipe
form, `echo "hi" | langstage-cli --demo -q`: a stdin message is read by the interactive
loop, which honored neither `-q` nor the `stdout.isatty()==False` gating, so it still
emitted the `····` separator rules, the `❯` prompt row (with `\x01\x02` bracketed-paste
bytes), the `Nms` timing line, and a trailing `Goodbye!` — ~380 bytes of chrome around a
25-byte reply. Two root causes: (1) the auto-quiet formula keyed only off a `MESSAGE`/
`-f`/`--verify` single-shot, so a stdin-fed run never triggered it; and (2) the
interactive loop applied no quiet gating at all. Both are fixed. The auto-quiet gate is
now "stdout is not a TTY **and** the input is non-interactive" — a single-shot arg/file/
verify **or** piped (non-TTY) stdin — and the conversation loop suppresses its
separators, prompt, timing line and `Goodbye!` under `_QUIET`, so a piped message
produces byte-identical output to the `-q "msg"` arg path (only the reply). The
interactive-loop path is kept (not rerouted to single-shot) deliberately: piped stdin
can still carry multiple lines and slash commands — `printf 'hi\nbye\n' | langstage-cli`
runs two clean turns, and `echo /config | langstage-cli` still drives the command — each
turn now just renders on the scriptable path. A live terminal session (stdin **is** a
tty, no `-q`) is unchanged: it keeps its header, separators, prompt, timing and farewell.

## 0.6.22 - 2026-07-23

### Fixed
Expand Down
57 changes: 43 additions & 14 deletions langstage_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1507,11 +1507,22 @@ def run_conversation_loop(
if single_shot:
return had_error

# Main conversation loop
# Main conversation loop.
#
# This loop also consumes a message on piped (non-TTY) stdin — `echo "hi" |
# langstage-cli` reads the line via input() and runs one turn, then EOF ends the
# loop. That path is scriptable, not an interactive session, so when _QUIET is
# set (via -q or the non-TTY auto-quiet above) every decorative element is
# suppressed: the `····` separators, the `❯` prompt (input() with no prompt, so
# no glyph and no bracketed-paste bytes), the `Nms` timing line, and the
# `Goodbye!`. What reaches stdout is then exactly the reply the MESSAGE-arg path
# emits. A real TTY session has _QUIET == False, so all of it renders unchanged.
# (gh #93)
while True:
try:
print(separator("dots"))
user_input = input(make_prompt()).strip()
if not _QUIET:
print(separator("dots"))
user_input = input("" if _QUIET else make_prompt()).strip()

if not user_input:
continue
Expand Down Expand Up @@ -1565,22 +1576,32 @@ def run_conversation_loop(
if user_input.lower() == "exit":
break

print() # Space before response
if not _QUIET:
print() # Space before response

# Run the agent (AG-UI is the only streaming path since langstage-core 1.0)
duration, _ = asyncio.run(
run_single_turn_agui(agui_agent, user_input, thread_id, interactive, verbose)
)
print_timing(duration, verbose)
print()
if _QUIET:
# Scriptable path (gh #93): cap the streamed reply with a single
# newline and emit no `Nms` timing line — exactly what the quiet
# MESSAGE-arg single-shot path does, so a piped one-liner produces
# byte-identical output whether the message is an arg or on stdin.
print()
else:
print_timing(duration, verbose)
print()

except (EOFError, KeyboardInterrupt):
break
except Exception as err:
print(f"\n{RED}✗ Error: {err}{RESET}\n")

# Print goodbye message
print_goodbye()
# Print goodbye message (interactive chrome — omitted on the scriptable/quiet
# path so a piped consumer never sees a trailing `Goodbye!`, gh #93).
if not _QUIET:
print_goodbye()


@click.command()
Expand Down Expand Up @@ -1738,14 +1759,22 @@ def main(
except (AttributeError, ValueError): # non-reconfigurable stream
pass

# Scriptable output (gh #53). A single-shot run (a MESSAGE arg or -f/--file) or
# a --verify preflight that is piped — stdout is not a TTY — auto-enables quiet
# so the consumer gets only the reply/verdict (no spinner or "Loaded" line);
# --quiet forces it in a terminal. Color is additionally stripped whenever stdout
# is not a TTY, matching well-behaved CLIs.
# Scriptable output (gh #53, gh #93). Auto-enable quiet when stdout is not a TTY
# AND the run is non-interactive input: an explicit single-shot (a MESSAGE arg,
# -f/--file, or a --verify preflight) OR a message on piped stdin
# (`echo "hi" | langstage-cli`). Piped stdin is non-interactive input — it is
# scriptable too, so it must not leak the REPL's `····` rules, the `❯` prompt
# (with bracketed-paste bytes), the `Nms` timing line or a trailing `Goodbye!`.
# The gate is `stdin is not a tty`, so a LIVE terminal session (a human typing,
# stdin IS a tty, no single-shot input) is never auto-quieted and its interactive
# UX is unchanged; --quiet still forces quiet anywhere. Color is additionally
# stripped whenever stdout is not a TTY, matching well-behaved CLIs.
_is_tty = _is_a_tty(sys.stdout)
_stdin_is_tty = _is_a_tty(sys.stdin)
global _QUIET
_QUIET = quiet or ((bool(message or prompt_file) or verify_agent) and not _is_tty)
_QUIET = quiet or (
(bool(message or prompt_file) or verify_agent or not _stdin_is_tty) and not _is_tty
)
if _QUIET or not _is_tty:
_disable_ansi()

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "langstage-cli"
version = "0.6.22"
version = "0.6.23"
description = "The terminal stage for your LangGraph agent — Claude Code-style CLI for any CompiledGraph"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
38 changes: 38 additions & 0 deletions tests/test_quiet_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
piped path these tests care about.
"""

import re

from click.testing import CliRunner

from langstage_cli import cli as c
Expand All @@ -36,6 +38,42 @@ def test_quiet_flag_forces_clean_output(tmp_path, monkeypatch):
assert "\x1b[" not in r.output and "⏺" not in r.output, r.output


def test_piped_stdin_quiet_output_matches_the_message_arg_path(tmp_path, monkeypatch):
# gh #93: a message on piped stdin (`echo "hi" | langstage-cli --demo -q`) used to
# be routed through the interactive REPL, which honored neither -q nor the non-TTY
# auto-quiet — so it leaked the `····` separator rules, the `❯` prompt row (with
# `\x01\x02` bracketed-paste bytes), the `Nms` timing line, and a trailing `Goodbye!`
# around the reply (~380 bytes of chrome). It must now emit exactly what the
# MESSAGE-arg quiet path emits: only the agent's reply. CliRunner's stdin and stdout
# are both non-TTYs, so `input="hi\n"` with no MESSAGE arg reproduces the piped pipe.
monkeypatch.chdir(tmp_path)
arg = CliRunner().invoke(main, ["--demo", "-q", "hi"])
stdin = CliRunner().invoke(main, ["--demo", "-q"], input="hi\n")
assert arg.exit_code == 0 and stdin.exit_code == 0, (arg.output, stdin.output)
# Byte-for-byte identical reply streams — the stdin path carries no extra chrome.
assert stdin.stdout == arg.stdout, repr((arg.stdout, stdin.stdout))
assert stdin.stdout == "(demo agent) You said: hi\n", repr(stdin.stdout)
# And none of the specific chrome the issue grepped for reaches the reply stream.
assert re.search(r"\d+ms", stdin.stdout) is None, repr(stdin.stdout) # no timing line
assert "·" not in stdin.stdout, repr(stdin.stdout) # no `····` separator rule
assert "❯" not in stdin.stdout, repr(stdin.stdout) # no prompt row
assert "\x01" not in stdin.stdout and "\x02" not in stdin.stdout # no bracketed-paste bytes
assert "Goodbye" not in stdin.stdout, repr(stdin.stdout) # no trailing farewell


def test_piped_stdin_auto_quiets_without_the_quiet_flag(tmp_path, monkeypatch):
# gh #93 / #53: the non-TTY auto-quiet must ALSO fire when the message arrives on
# stdin, with no -q — `echo "hi" | langstage-cli --demo | tool` is clean by default,
# exactly like the MESSAGE-arg auto-quiet. Piped stdin is non-interactive input, so
# the run is scriptable even without the explicit flag.
monkeypatch.chdir(tmp_path)
r = CliRunner().invoke(main, ["--demo"], input="hi\n")
assert r.exit_code == 0, r.output
assert r.stdout == "(demo agent) You said: hi\n", repr(r.stdout)
assert re.search(r"\d+ms", r.stdout) is None, repr(r.stdout)
assert "·" not in r.stdout and "❯" not in r.stdout and "Goodbye" not in r.stdout


def test_make_prompt_uses_disabled_color_globals_after_ansi_is_disabled():
c._disable_ansi()

Expand Down
Loading