langstage-cli 0.6.43__tar.gz → 0.6.45__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {langstage_cli-0.6.43/langstage_cli.egg-info → langstage_cli-0.6.45}/PKG-INFO +4 -2
  2. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/README.md +3 -1
  3. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli/cli.py +75 -15
  4. {langstage_cli-0.6.43 → langstage_cli-0.6.45/langstage_cli.egg-info}/PKG-INFO +4 -2
  5. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli.egg-info/SOURCES.txt +2 -0
  6. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/pyproject.toml +1 -1
  7. langstage_cli-0.6.45/tests/test_dumb_term.py +190 -0
  8. langstage_cli-0.6.45/tests/test_tool_card_args.py +100 -0
  9. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/LICENSE +0 -0
  10. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/deepagent_code/__init__.py +0 -0
  11. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli/__init__.py +0 -0
  12. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli/agui_stream.py +0 -0
  13. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli/config.py +0 -0
  14. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli/exit_codes.py +0 -0
  15. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli/sessions.py +0 -0
  16. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli.egg-info/dependency_links.txt +0 -0
  17. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli.egg-info/entry_points.txt +0 -0
  18. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli.egg-info/requires.txt +0 -0
  19. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli.egg-info/top_level.txt +0 -0
  20. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/setup.cfg +0 -0
  21. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_advertised_wave4.py +0 -0
  22. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_agui_stream.py +0 -0
  23. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_approval_menu_abort.py +0 -0
  24. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_async_mode.py +0 -0
  25. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_checkpointer.py +0 -0
  26. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_cli.py +0 -0
  27. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_cli_help.py +0 -0
  28. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_codeconfig.py +0 -0
  29. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_config.py +0 -0
  30. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_config_command.py +0 -0
  31. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_configurable.py +0 -0
  32. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_deferred_pass.py +0 -0
  33. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_exit_codes.py +0 -0
  34. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_generic_interrupt.py +0 -0
  35. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_help_render.py +0 -0
  36. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_history_persist.py +0 -0
  37. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_hitl_approval_preview.py +0 -0
  38. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_hitl_resume_warning.py +0 -0
  39. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_init.py +0 -0
  40. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_interrupt_non_tty.py +0 -0
  41. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_legacy_config_home.py +0 -0
  42. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_load_error.py +0 -0
  43. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_message_boundaries.py +0 -0
  44. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_no_color.py +0 -0
  45. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_no_interactive_approve.py +0 -0
  46. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_no_persist_resume.py +0 -0
  47. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_persist_env.py +0 -0
  48. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_quiet_output.py +0 -0
  49. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_reasoning_render.py +0 -0
  50. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_rename_shim.py +0 -0
  51. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_render_markdown.py +0 -0
  52. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_session_lifecycle.py +0 -0
  53. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_sessions.py +0 -0
  54. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_show_config.py +0 -0
  55. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_show_config_malformed.py +0 -0
  56. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_spec_loading_core.py +0 -0
  57. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_spec_resolution.py +0 -0
  58. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_spinner_reply_survives.py +0 -0
  59. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_stdin_interactive.py +0 -0
  60. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_stdin_single_shot.py +0 -0
  61. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_stream_mode.py +0 -0
  62. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_streaming_marker.py +0 -0
  63. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_terminal_width.py +0 -0
  64. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_toml_relative_paths.py +0 -0
  65. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_tool_output_escape.py +0 -0
  66. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_turn_error_verbose.py +0 -0
  67. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_turn_exit_and_render.py +0 -0
  68. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_unicode_console.py +0 -0
  69. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_verbose_command.py +0 -0
  70. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_verify_flag.py +0 -0
  71. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_workspace.py +0 -0
  72. {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_wrong_type_graph.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langstage-cli
3
- Version: 0.6.43
3
+ Version: 0.6.45
4
4
  Summary: The terminal stage for your LangGraph agent — Claude Code-style CLI for any CompiledGraph
5
5
  Author-email: Kedar Dabhadkar <kdabhadk@gmail.com>
6
6
  License-Expression: MIT
@@ -322,7 +322,9 @@ cat prompt.txt | langstage-cli --demo
322
322
 
323
323
  Pass `-q/--quiet` to force the same clean output in a terminal. Set
324
324
  [`NO_COLOR`](https://no-color.org) to any non-empty value to turn color off in a terminal
325
- too; the layout and spinner stay, uncolored.
325
+ too; the layout and spinner stay, uncolored. With `TERM=dumb` (Emacs shell, `vim :!`)
326
+ the CLI sends no escape sequences at all: no color, a spinner that prints once, and an
327
+ approval menu you answer by typing a number.
326
328
 
327
329
  ### Exit codes
328
330
 
@@ -284,7 +284,9 @@ cat prompt.txt | langstage-cli --demo
284
284
 
285
285
  Pass `-q/--quiet` to force the same clean output in a terminal. Set
286
286
  [`NO_COLOR`](https://no-color.org) to any non-empty value to turn color off in a terminal
287
- too; the layout and spinner stay, uncolored.
287
+ too; the layout and spinner stay, uncolored. With `TERM=dumb` (Emacs shell, `vim :!`)
288
+ the CLI sends no escape sequences at all: no color, a spinner that prints once, and an
289
+ approval menu you answer by typing a number.
288
290
 
289
291
  ### Exit codes
290
292
 
@@ -61,6 +61,16 @@ BRIGHT_GREEN, BRIGHT_YELLOW = "\033[92m", "\033[93m"
61
61
  # strip ANSI, so the pipe/file receives ONLY the agent's reply. Toggled once in
62
62
  # main(); the render helpers below read it as a module global.
63
63
  _QUIET = False
64
+ # A dumb terminal (TERM=dumb: Emacs shell/compile, vim :!, serial consoles) shows escape
65
+ # sequences as literal text, so it gets none at all: no color (via _disable_ansi()), no
66
+ # animated spinner, no line clears or cursor hide/show, and a typed-number approval
67
+ # menu instead of the arrow-key one. Set once in main(). (gh #182)
68
+ _DUMB_TERM = False
69
+
70
+
71
+ def _is_dumb_term() -> bool:
72
+ """``TERM=dumb``: the long-standing "this terminal can't do escapes" convention."""
73
+ return os.environ.get("TERM", "").strip().lower() == "dumb"
64
74
 
65
75
 
66
76
  def _is_a_tty(stream) -> bool:
@@ -298,7 +308,7 @@ def rl_wrap(code: str) -> str:
298
308
  This causes issues with line wrapping when using input().
299
309
  Wrapping with \\001 and \\002 tells readline to ignore these characters.
300
310
  """
301
- if HAS_READLINE:
311
+ if HAS_READLINE and code:
302
312
  return f"\001{code}\002"
303
313
  return code
304
314
 
@@ -368,10 +378,17 @@ class Spinner:
368
378
  time.sleep(0.08)
369
379
 
370
380
  def start(self):
371
- """Start the spinner."""
372
- self.running = True
381
+ """Start the spinner. On a dumb terminal it can't redraw a line, so print the
382
+ message once as a plain line and don't animate (gh #182)."""
373
383
  self._stopped = False
374
384
  self.start_time = time.time()
385
+ if _DUMB_TERM:
386
+ try:
387
+ print(f"{self.message}...", flush=True)
388
+ except (UnicodeEncodeError, ValueError):
389
+ pass
390
+ return
391
+ self.running = True
375
392
  self.thread = threading.Thread(target=self._spin, daemon=True)
376
393
  self.thread.start()
377
394
 
@@ -399,6 +416,8 @@ class Spinner:
399
416
  self.running = False
400
417
  if self.thread:
401
418
  self.thread.join(timeout=0.2)
419
+ if _DUMB_TERM:
420
+ return # start() printed a whole line; there is nothing to clear (gh #182)
402
421
  # Clear the spinner line. With color off (NO_COLOR, or stdout not a terminal:
403
422
  # _disable_ansi() has blanked RESET), overwrite it with spaces instead of CSI 2K
404
423
  # so that output carries no escape sequence at all. (gh #172)
@@ -1121,20 +1140,31 @@ def _absolutize_file_spec(spec: str) -> str:
1121
1140
  return f"{Path(path_part).expanduser().resolve()}{suffix}"
1122
1141
 
1123
1142
 
1124
- def get_tool_arg_preview(args: Dict[str, Any]) -> str:
1125
- """Get a preview of the first argument value (nanocode style).
1143
+ # Per-value cap on the tool-call card (default output; ``-v`` shows values in full).
1144
+ _TOOL_ARG_VALUE_MAX = 50
1145
+
1126
1146
 
1127
- The value is escaped before it is truncated, so a tool arg can't move the cursor
1128
- or retitle the terminal, and a cut can't leave half an escape sequence live (gh #180).
1147
+ def get_tool_arg_preview(args: Dict[str, Any], verbose: bool = False) -> str:
1148
+ """The arguments a tool was called with, for the tool-call card.
1149
+
1150
+ A lone argument shows as its bare value (``read_file └─ docs/a.md``). Several show
1151
+ as ``name=value`` pairs, every one of them: showing only the first misstated the
1152
+ call, ``add(a=17, b=25)`` read as "add 17" (gh #184). Each value is capped at 50
1153
+ characters unless ``verbose``. Values are escaped before they are truncated, so a
1154
+ tool arg can't move the cursor or retitle the terminal, and a cut can't leave half
1155
+ an escape sequence live (gh #180).
1129
1156
  """
1130
1157
  if not args:
1131
1158
  return ""
1132
- # Get first value
1133
- first_val = _escape_controls(str(list(args.values())[0]))
1134
- # Truncate if needed
1135
- if len(first_val) > 50:
1136
- return first_val[:50] + "..."
1137
- return first_val
1159
+ limit = None if verbose else _TOOL_ARG_VALUE_MAX
1160
+ if not isinstance(args, dict):
1161
+ return _approval_text(args, limit)
1162
+ if len(args) == 1:
1163
+ (value,) = args.values()
1164
+ return _approval_text(str(value), limit)
1165
+ return ", ".join(
1166
+ f"{_approval_text(str(k), None)}={_approval_text(v, limit)}" for k, v in args.items()
1167
+ )
1138
1168
 
1139
1169
 
1140
1170
  # Per-value cap for the HITL approval preview. Values are truncated, never dropped: the
@@ -1476,7 +1506,7 @@ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
1476
1506
  for tool_call in chunk["tool_calls"]:
1477
1507
  tool_name = _escape_controls(str(tool_call["name"])) # gh #180
1478
1508
  args = tool_call.get("args", {})
1479
- arg_preview = get_tool_arg_preview(args)
1509
+ arg_preview = get_tool_arg_preview(args, verbose)
1480
1510
 
1481
1511
  safe_print(f"\n{GREEN}● {tool_name}{RESET}")
1482
1512
  if arg_preview:
@@ -1633,6 +1663,8 @@ def select_option(options: List[str], prompt: str = "Select an option:") -> int:
1633
1663
  Returns:
1634
1664
  Index of selected option (0-based)
1635
1665
  """
1666
+ if _DUMB_TERM:
1667
+ return _select_option_typed(options, prompt)
1636
1668
  selected = 0
1637
1669
  num_options = len(options)
1638
1670
 
@@ -1687,6 +1719,30 @@ def select_option(options: List[str], prompt: str = "Select an option:") -> int:
1687
1719
  print("\033[?25h", end="")
1688
1720
 
1689
1721
 
1722
+ def _select_option_typed(options: List[str], prompt: str) -> int:
1723
+ """The approval menu for a dumb terminal: a numbered list and a typed choice.
1724
+
1725
+ The arrow-key menu redraws itself with cursor-up and line-clear escapes, which a
1726
+ dumb terminal prints as literal text, and editor shells (Emacs ``M-x shell``) send
1727
+ whole lines, not keypresses. Enter alone picks the first option. Ctrl-C or EOF
1728
+ abandons the prompt the same way the arrow-key menu does: exit 2, paused. (gh #182)
1729
+ """
1730
+ print(f"\n{prompt}")
1731
+ for i, opt in enumerate(options, 1):
1732
+ print(f" {i}. {opt}")
1733
+ while True:
1734
+ try:
1735
+ raw = input(f"Choose 1-{len(options)} [1]: ").strip()
1736
+ except (KeyboardInterrupt, EOFError):
1737
+ print()
1738
+ sys.exit(EXIT_PAUSED)
1739
+ if not raw:
1740
+ return 0
1741
+ if raw.isdigit() and 1 <= int(raw) <= len(options):
1742
+ return int(raw) - 1
1743
+ print(f"Enter a number from 1 to {len(options)}.")
1744
+
1745
+
1690
1746
  def handle_interrupt_input(num_actions: int = 1, is_generic: bool = False) -> Any:
1691
1747
  """
1692
1748
  Handle user input for an interrupt using arrow key navigation, returning the
@@ -3215,13 +3271,17 @@ def main(
3215
3271
  # stripped whenever stdout is not a TTY, matching well-behaved CLIs, and whenever
3216
3272
  # NO_COLOR is set to a non-empty value (https://no-color.org), a live terminal
3217
3273
  # included. An empty NO_COLOR doesn't count, per the convention. (gh #172)
3274
+ # TERM=dumb also strips color, and the spinner and approval menu drop their
3275
+ # escape-driven redraws (gh #182).
3218
3276
  _is_tty = _is_a_tty(sys.stdout)
3219
3277
  _stdin_is_tty = _stdin_is_interactive() # a mintty pty counts; NUL doesn't
3220
3278
  global _QUIET
3221
3279
  _QUIET = quiet or (
3222
3280
  (bool(message or prompt_file) or verify_agent or not _stdin_is_tty) and not _is_tty
3223
3281
  )
3224
- if _QUIET or not _is_tty or os.environ.get("NO_COLOR"):
3282
+ global _DUMB_TERM
3283
+ _DUMB_TERM = _is_dumb_term()
3284
+ if _QUIET or not _is_tty or os.environ.get("NO_COLOR") or _DUMB_TERM:
3225
3285
  _disable_ansi()
3226
3286
 
3227
3287
  # `langstage-cli init` — scaffold a runnable starter agent + langstage.toml, then
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langstage-cli
3
- Version: 0.6.43
3
+ Version: 0.6.45
4
4
  Summary: The terminal stage for your LangGraph agent — Claude Code-style CLI for any CompiledGraph
5
5
  Author-email: Kedar Dabhadkar <kdabhadk@gmail.com>
6
6
  License-Expression: MIT
@@ -322,7 +322,9 @@ cat prompt.txt | langstage-cli --demo
322
322
 
323
323
  Pass `-q/--quiet` to force the same clean output in a terminal. Set
324
324
  [`NO_COLOR`](https://no-color.org) to any non-empty value to turn color off in a terminal
325
- too; the layout and spinner stay, uncolored.
325
+ too; the layout and spinner stay, uncolored. With `TERM=dumb` (Emacs shell, `vim :!`)
326
+ the CLI sends no escape sequences at all: no color, a spinner that prints once, and an
327
+ approval menu you answer by typing a number.
326
328
 
327
329
  ### Exit codes
328
330
 
@@ -26,6 +26,7 @@ tests/test_config.py
26
26
  tests/test_config_command.py
27
27
  tests/test_configurable.py
28
28
  tests/test_deferred_pass.py
29
+ tests/test_dumb_term.py
29
30
  tests/test_exit_codes.py
30
31
  tests/test_generic_interrupt.py
31
32
  tests/test_help_render.py
@@ -58,6 +59,7 @@ tests/test_stream_mode.py
58
59
  tests/test_streaming_marker.py
59
60
  tests/test_terminal_width.py
60
61
  tests/test_toml_relative_paths.py
62
+ tests/test_tool_card_args.py
61
63
  tests/test_tool_output_escape.py
62
64
  tests/test_turn_error_verbose.py
63
65
  tests/test_turn_exit_and_render.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "langstage-cli"
7
- version = "0.6.43"
7
+ version = "0.6.45"
8
8
  description = "The terminal stage for your LangGraph agent — Claude Code-style CLI for any CompiledGraph"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,190 @@
1
+ """``TERM=dumb`` gets no escape sequences at all (gh #182).
2
+
3
+ A dumb terminal (Emacs ``M-x shell``/``compile``, ``vim :!``, serial consoles) prints
4
+ escapes as literal text. ``main()`` honored ``NO_COLOR`` but not ``TERM=dumb``, and the
5
+ spinner and approval menu wrote line clears and cursor hide/show on their own. Now
6
+ ``TERM=dumb`` strips color, the spinner prints one plain line, and the approval menu is
7
+ a numbered list answered by typing a number.
8
+ """
9
+
10
+ import io
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from contextlib import redirect_stdout
16
+
17
+ import pytest
18
+ from click.testing import CliRunner
19
+
20
+ from langstage_cli import cli
21
+
22
+
23
+ @pytest.fixture
24
+ def dumb(monkeypatch):
25
+ monkeypatch.setattr(cli, "_DUMB_TERM", True)
26
+
27
+
28
+ def _typed(monkeypatch, *lines):
29
+ it = iter(lines)
30
+ shown = []
31
+
32
+ def fake_input(prompt=""):
33
+ shown.append(prompt)
34
+ line = next(it)
35
+ if isinstance(line, BaseException):
36
+ raise line
37
+ return line
38
+
39
+ monkeypatch.setattr("builtins.input", fake_input)
40
+ return shown
41
+
42
+
43
+ @pytest.mark.parametrize("value", ["dumb", "DUMB", " dumb "])
44
+ def test_term_dumb_is_detected(monkeypatch, value):
45
+ monkeypatch.setenv("TERM", value)
46
+ assert cli._is_dumb_term()
47
+
48
+
49
+ @pytest.mark.parametrize("value", ["xterm-256color", "", "dumber"])
50
+ def test_other_terms_are_not_dumb(monkeypatch, value):
51
+ monkeypatch.setenv("TERM", value)
52
+ assert not cli._is_dumb_term()
53
+
54
+
55
+ def test_term_dumb_strips_every_escape_on_a_terminal(tmp_path, monkeypatch):
56
+ monkeypatch.chdir(tmp_path)
57
+ monkeypatch.setattr(cli, "_is_a_tty", lambda stream: True)
58
+ monkeypatch.setenv("TERM", "dumb")
59
+ r = CliRunner().invoke(cli.main, ["--demo", "hi"])
60
+ assert r.exit_code == 0, r.output
61
+ assert "You said: hi" in r.output and "Loaded" in r.output, r.output
62
+ assert "\x1b" not in r.output, repr(r.output)
63
+ # The spinner never redraws: no carriage return anywhere.
64
+ assert "\r" not in r.output, repr(r.output)
65
+
66
+
67
+ def test_spinner_prints_one_plain_line_on_a_dumb_terminal(dumb, capsys):
68
+ spinner = cli.Spinner("Thinking")
69
+ spinner.start()
70
+ time.sleep(0.2) # an animating spinner would have drawn a couple of frames by now
71
+ spinner.stop()
72
+ assert capsys.readouterr().out == "Thinking...\n"
73
+
74
+
75
+ def test_menu_is_numbered_and_typed_on_a_dumb_terminal(dumb, monkeypatch):
76
+ monkeypatch.setattr(cli, "get_key", lambda: pytest.fail("no keypress reading"))
77
+ prompts = _typed(monkeypatch, "2")
78
+ buf = io.StringIO()
79
+ with redirect_stdout(buf):
80
+ choice = cli.select_option(["Approve", "Reject", "Exit"], "How would you like to proceed?")
81
+ assert choice == 1
82
+ out = buf.getvalue()
83
+ assert " 1. Approve\n 2. Reject\n 3. Exit\n" in out, repr(out)
84
+ assert "\x1b" not in out + "".join(prompts)
85
+
86
+
87
+ def test_menu_enter_picks_the_first_option(dumb, monkeypatch):
88
+ _typed(monkeypatch, "")
89
+ with redirect_stdout(io.StringIO()):
90
+ assert cli.select_option(["Approve", "Reject"]) == 0
91
+
92
+
93
+ def test_menu_asks_again_on_a_bad_choice(dumb, monkeypatch):
94
+ prompts = _typed(monkeypatch, "9", "yes", "2")
95
+ buf = io.StringIO()
96
+ with redirect_stdout(buf):
97
+ assert cli.select_option(["Approve", "Reject"]) == 1
98
+ assert len(prompts) == 3
99
+ assert buf.getvalue().count("Enter a number from 1 to 2.") == 2
100
+
101
+
102
+ @pytest.mark.parametrize("abort", [KeyboardInterrupt(), EOFError()], ids=["ctrl-c", "eof"])
103
+ def test_leaving_the_typed_menu_exits_paused(dumb, monkeypatch, abort):
104
+ _typed(monkeypatch, abort)
105
+ with redirect_stdout(io.StringIO()), pytest.raises(SystemExit) as exc:
106
+ cli.select_option(["Approve", "Exit"])
107
+ assert exc.value.code == 2
108
+
109
+
110
+ def _run_on_pty(tmp_path, term, args, answers=b""):
111
+ import pty
112
+ import select
113
+
114
+ env = {k: v for k, v in os.environ.items() if k not in ("NO_COLOR", "TERM")}
115
+ env["LANGSTAGE_CLI_SESSIONS_DIR"] = str(tmp_path / "_sessions")
116
+ env["TERM"] = term
117
+ master, slave = pty.openpty()
118
+ proc = subprocess.Popen(
119
+ [sys.executable, "-c", "from langstage_cli.cli import main; main()", *args],
120
+ stdin=slave,
121
+ stdout=slave,
122
+ stderr=slave,
123
+ cwd=tmp_path,
124
+ env=env,
125
+ close_fds=True,
126
+ )
127
+ os.close(slave)
128
+ out = b""
129
+ sent = False
130
+ deadline = time.monotonic() + 60
131
+ try:
132
+ while time.monotonic() < deadline:
133
+ ready, _, _ = select.select([master], [], [], 0.5)
134
+ if not ready:
135
+ if proc.poll() is not None:
136
+ break
137
+ continue
138
+ try:
139
+ data = os.read(master, 4096)
140
+ except OSError: # EIO: the child closed the terminal
141
+ break
142
+ if not data:
143
+ break
144
+ out += data
145
+ if answers and not sent and b"Choose 1-" in out:
146
+ os.write(master, answers)
147
+ sent = True
148
+ proc.wait(timeout=10)
149
+ finally:
150
+ if proc.poll() is None:
151
+ proc.kill()
152
+ os.close(master)
153
+ return proc.returncode, out.decode("utf-8", errors="replace")
154
+
155
+
156
+ _HITL_AGENT = """
157
+ from langgraph.graph import StateGraph, START, END
158
+ from langgraph.graph.message import MessagesState
159
+ from langgraph.checkpoint.memory import MemorySaver
160
+ from langgraph.types import interrupt
161
+ from langchain_core.messages import AIMessage
162
+
163
+ def ask(state):
164
+ decision = interrupt({"action": "delete_file", "path": "/etc/hosts"})
165
+ return {"messages": [AIMessage(content=f"Decision was: {decision!r}")]}
166
+
167
+ g = StateGraph(MessagesState)
168
+ g.add_node("ask", ask)
169
+ g.add_edge(START, "ask")
170
+ g.add_edge("ask", END)
171
+ graph = g.compile(checkpointer=MemorySaver())
172
+ """
173
+
174
+
175
+ @pytest.mark.skipif(sys.platform == "win32", reason="needs a POSIX pty")
176
+ def test_no_escapes_on_a_real_dumb_terminal(tmp_path):
177
+ code, out = _run_on_pty(tmp_path, "dumb", ["--demo", "hi"])
178
+ assert code == 0, out
179
+ assert "You said: hi" in out and "╭" in out, repr(out)
180
+ assert "\x1b" not in out, repr(out)
181
+
182
+
183
+ @pytest.mark.skipif(sys.platform == "win32", reason="needs a POSIX pty")
184
+ def test_approval_menu_works_on_a_real_dumb_terminal(tmp_path):
185
+ (tmp_path / "hitl_agent.py").write_text(_HITL_AGENT, encoding="utf-8")
186
+ code, out = _run_on_pty(tmp_path, "dumb", ["-a", "hitl_agent.py:graph", "go"], b"1\n")
187
+ assert "1. " in out and "Choose 1-" in out, repr(out)
188
+ assert "Decision was" in out, repr(out)
189
+ assert code == 0, out
190
+ assert "\x1b" not in out, repr(out)
@@ -0,0 +1,100 @@
1
+ """The tool-call card shows every argument, by name (gh #184).
2
+
3
+ It used to show only the first argument's value, so ``add(a=17, b=25)`` rendered as
4
+ ``add └─ 17`` and ``transfer_funds(to, amount, currency)`` as ``└─ alice``, in default
5
+ and ``-v`` output alike. Under ``--no-interactive`` the card is the only record of
6
+ what ran.
7
+ """
8
+
9
+ import io
10
+ import re
11
+ from contextlib import redirect_stdout
12
+
13
+ import pytest
14
+
15
+ pytest.importorskip("ag_ui_langgraph")
16
+
17
+ from langchain_core.messages import AIMessage, ToolMessage # noqa: E402
18
+ from langgraph.graph import END, START, StateGraph # noqa: E402
19
+ from langgraph.graph.message import MessagesState # noqa: E402
20
+
21
+ from langstage_cli import cli # noqa: E402
22
+ from langstage_cli.agui_stream import build_session_agent # noqa: E402
23
+ from langstage_cli.cli import get_tool_arg_preview, run_single_turn_agui # noqa: E402
24
+
25
+ _SGR = re.compile(r"\x1b\[[0-9;]*m")
26
+ TRANSFER = {"to": "alice", "amount": 1000000, "currency": "USD"}
27
+
28
+
29
+ def test_every_argument_is_named():
30
+ assert get_tool_arg_preview({"a": 17, "b": 25}) == "a=17, b=25"
31
+ assert get_tool_arg_preview(TRANSFER) == "to=alice, amount=1000000, currency=USD"
32
+
33
+
34
+ def test_a_single_argument_stays_a_bare_value():
35
+ assert get_tool_arg_preview({"path": "docs/a.md"}) == "docs/a.md"
36
+ assert get_tool_arg_preview({}) == ""
37
+
38
+
39
+ def test_values_are_capped_but_never_dropped():
40
+ long = "x" * 200
41
+ preview = get_tool_arg_preview({"path": "a.md", "content": long, "mode": "w"})
42
+ assert preview == f"path=a.md, content={'x' * 50}..., mode=w"
43
+ # Structured values read as JSON, not Python reprs.
44
+ assert (
45
+ get_tool_arg_preview({"ids": [1, 2], "opts": {"k": "v"}}) == 'ids=[1, 2], opts={"k": "v"}'
46
+ )
47
+
48
+
49
+ def test_verbose_shows_values_in_full():
50
+ long = "y" * 200
51
+ assert (
52
+ get_tool_arg_preview({"path": "a.md", "content": long}, verbose=True)
53
+ == f"path=a.md, content={long}"
54
+ )
55
+ assert get_tool_arg_preview({"q": long}, verbose=True) == long
56
+
57
+
58
+ def test_names_and_values_are_escaped():
59
+ preview = get_tool_arg_preview({"p\x1b[2K": "a\x1b]0;t\x07", "b": "c\nd"})
60
+ assert "\x1b" not in preview and "\x07" not in preview and "\n" not in preview
61
+ assert preview == r"p\x1b[2K=a\x1b]0;t\x07, b=c\nd"
62
+
63
+
64
+ def _transfer_graph():
65
+ def call(state):
66
+ tc = {"id": "t1", "name": "transfer_funds", "args": TRANSFER}
67
+ return {"messages": [AIMessage(content="", tool_calls=[tc])]}
68
+
69
+ def tool(state):
70
+ return {"messages": [ToolMessage(content="ok", tool_call_id="t1")]}
71
+
72
+ def reply(state):
73
+ return {"messages": [AIMessage(content="Transferred.")]}
74
+
75
+ g = StateGraph(MessagesState)
76
+ for n, f in [("call", call), ("tool", tool), ("reply", reply)]:
77
+ g.add_node(n, f)
78
+ g.add_edge(START, "call")
79
+ g.add_edge("call", "tool")
80
+ g.add_edge("tool", "reply")
81
+ g.add_edge("reply", END)
82
+ return g.compile()
83
+
84
+
85
+ @pytest.mark.parametrize("verbose", [False, True])
86
+ async def test_card_in_a_real_turn(monkeypatch, verbose):
87
+ """The issue's keyless repro, through the AG-UI stream the CLI renders."""
88
+ monkeypatch.setattr(cli, "Spinner", lambda *a, **k: None)
89
+ buf = io.StringIO()
90
+ with redirect_stdout(buf):
91
+ await run_single_turn_agui(
92
+ build_session_agent(_transfer_graph()),
93
+ "pay alice",
94
+ "t-184",
95
+ interactive=False,
96
+ verbose=verbose,
97
+ )
98
+ plain = _SGR.sub("", buf.getvalue())
99
+ assert "● transfer_funds" in plain, plain
100
+ assert "└─ to=alice, amount=1000000, currency=USD" in plain, plain
File without changes
File without changes