mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/cli: multiline \"\"\" input and /edit via EDITOR
Add triple-quote block messages in the REPL and /edit to compose a turn in $EDITOR (temp file). Empty multiline or empty edit cancels.
This commit is contained in:
@@ -114,15 +114,15 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i
|
|||||||
- G1: `ReasoningDeltaEvent`, `/stream`, `/verbose`
|
- G1: `ReasoningDeltaEvent`, `/stream`, `/verbose`
|
||||||
- G2: `/rename`, `/delete` (confirm), `/export md|json`
|
- G2: `/rename`, `/delete` (confirm), `/export md|json`
|
||||||
- G2.5: Click slash registry (`cli/slash.py`) + `awaitlet` for sync Click / async memory; auto `/help`; completer from `slash.list_commands`
|
- G2.5: Click slash registry (`cli/slash.py`) + `awaitlet` for sync Click / async memory; auto `/help`; completer from `slash.list_commands`
|
||||||
|
- G3: multiline `"""` … `"""` input (`cli/input_text.py`); `/edit` via `$EDITOR` (`edit_text_in_editor`)
|
||||||
|
|
||||||
**Planned**
|
**Planned**
|
||||||
|
|
||||||
- **G3 — Input**: multiline (`"""` … `"""`) and/or `/edit` via `$EDITOR`
|
|
||||||
- **G4 — One-shot**: `plyngent chat -p/--prompt` (and stdin non-TTY); exit codes; non-interactive safety (`--yes` / deny destructive)
|
- **G4 — One-shot**: `plyngent chat -p/--prompt` (and stdin non-TTY); exit codes; non-interactive safety (`--yes` / deny destructive)
|
||||||
- **G5 — Hardening**: tool timeouts/defaults review, config error clarity, cancel edges, optional `--log-level`, no secrets in status/export
|
- **G5 — Hardening**: tool timeouts/defaults review, config error clarity, cancel edges, optional `--log-level`, no secrets in status/export
|
||||||
- **G6 — Docs**: real README, example TOML, CLAUDE/help accuracy
|
- **G6 — Docs**: real README, example TOML, CLAUDE/help accuracy
|
||||||
|
|
||||||
Milestone order: G3 → G4 → G5 → G6.
|
Milestone order: G4 → G5 → G6.
|
||||||
|
|
||||||
- **Phase H**: multi-tenant platform (`router/`, auth, sandboxed tools, web).
|
- **Phase H**: multi-tenant platform (`router/`, auth, sandboxed tools, web).
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -60,13 +61,23 @@ def ensure_config_file(path: Path) -> None:
|
|||||||
_ = path.write_text(_MINIMAL_CONFIG, encoding="utf-8")
|
_ = path.write_text(_MINIMAL_CONFIG, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def open_in_editor(path: Path, *, editor: str | None = None) -> None:
|
def open_in_editor(
|
||||||
"""Open ``path`` with ``EDITOR`` (supports values like ``codium --wait``)."""
|
path: Path,
|
||||||
|
*,
|
||||||
|
editor: str | None = None,
|
||||||
|
ensure_exists: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""Open ``path`` with ``EDITOR`` (supports values like ``codium --wait``).
|
||||||
|
|
||||||
|
When ``ensure_exists`` is true (default), create a minimal config template
|
||||||
|
if the file is missing (used for ``plyngent config edit``).
|
||||||
|
"""
|
||||||
editor_cmd = editor if editor is not None else get_editor()
|
editor_cmd = editor if editor is not None else get_editor()
|
||||||
if editor_cmd is None:
|
if editor_cmd is None:
|
||||||
msg = "EDITOR is not set"
|
msg = "EDITOR is not set"
|
||||||
raise click.ClickException(msg)
|
raise click.ClickException(msg)
|
||||||
|
|
||||||
|
if ensure_exists:
|
||||||
ensure_config_file(path)
|
ensure_config_file(path)
|
||||||
try:
|
try:
|
||||||
argv = [*shlex.split(editor_cmd, posix=os.name != "nt"), str(path)]
|
argv = [*shlex.split(editor_cmd, posix=os.name != "nt"), str(path)]
|
||||||
@@ -91,6 +102,40 @@ def open_in_editor(path: Path, *, editor: str | None = None) -> None:
|
|||||||
raise click.ClickException(msg)
|
raise click.ClickException(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def edit_text_in_editor(initial: str = "", *, suffix: str = ".md") -> str | None:
|
||||||
|
"""Edit ``initial`` in ``$EDITOR``; return text or ``None`` if empty/cancelled.
|
||||||
|
|
||||||
|
Uses a temporary file. Does not create a config template.
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
if get_editor() is None:
|
||||||
|
msg = "EDITOR is not set; cannot /edit"
|
||||||
|
raise click.ClickException(msg)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
suffix=suffix,
|
||||||
|
prefix="plyngent-edit-",
|
||||||
|
delete=False,
|
||||||
|
) as handle:
|
||||||
|
path = Path(handle.name)
|
||||||
|
_ = handle.write(initial)
|
||||||
|
|
||||||
|
try:
|
||||||
|
open_in_editor(path, ensure_exists=False)
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
cleaned = text.rstrip("\n")
|
||||||
|
if not cleaned.strip():
|
||||||
|
return None
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
def prompt_edit_config(path: Path, *, reason: str | None = None) -> bool:
|
def prompt_edit_config(path: Path, *, reason: str | None = None) -> bool:
|
||||||
"""If ``EDITOR`` is set, ask whether to edit ``path``. Returns True if opened."""
|
"""If ``EDITOR`` is set, ask whether to edit ``path``. Returns True if opened."""
|
||||||
if get_editor() is None:
|
if get_editor() is None:
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
_TRIPLE = '"""'
|
||||||
|
|
||||||
|
|
||||||
|
def parse_triple_quote_line(line: str) -> tuple[str, bool] | None:
|
||||||
|
"""If ``line`` starts a ``\"\"\"`` block, return ``(content_so_far, complete)``.
|
||||||
|
|
||||||
|
``complete`` is True when the closing marker is on the same line
|
||||||
|
(e.g. ``\"\"\"hello\"\"\"`` → ``(\"hello\", True)``).
|
||||||
|
"""
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped.startswith(_TRIPLE):
|
||||||
|
return None
|
||||||
|
rest = stripped[len(_TRIPLE) :]
|
||||||
|
if rest.endswith(_TRIPLE) and len(rest) >= len(_TRIPLE):
|
||||||
|
inner = rest[: -len(_TRIPLE)]
|
||||||
|
return inner, True
|
||||||
|
return rest, False
|
||||||
|
|
||||||
|
|
||||||
|
def finish_triple_quote_line(line: str) -> tuple[str, bool]:
|
||||||
|
"""Parse a continuation line. Returns ``(content_piece, is_closer)``."""
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped == _TRIPLE:
|
||||||
|
return "", True
|
||||||
|
# Allow closing as trailing """ on a content line.
|
||||||
|
if line.rstrip().endswith(_TRIPLE):
|
||||||
|
without = line.rstrip()[: -len(_TRIPLE)]
|
||||||
|
# Don't treat a line that is only """ as content (handled above).
|
||||||
|
return without.rstrip("\n"), True
|
||||||
|
return line, False
|
||||||
|
|
||||||
|
|
||||||
|
def assemble_multiline(
|
||||||
|
opening_line: str,
|
||||||
|
*,
|
||||||
|
read_line: Callable[[], str],
|
||||||
|
echo: Callable[[str], None] | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Read a full triple-quoted message. Empty body → ``None`` (cancel)."""
|
||||||
|
parsed = parse_triple_quote_line(opening_line)
|
||||||
|
if parsed is None:
|
||||||
|
text = opening_line.strip()
|
||||||
|
return text or None
|
||||||
|
first, complete = parsed
|
||||||
|
if complete:
|
||||||
|
return first if first.strip() else None
|
||||||
|
|
||||||
|
parts: list[str] = []
|
||||||
|
if first:
|
||||||
|
parts.append(first)
|
||||||
|
if echo is not None:
|
||||||
|
echo(f"(multiline; end with {_TRIPLE})")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line = read_line()
|
||||||
|
except EOFError, KeyboardInterrupt:
|
||||||
|
if echo is not None:
|
||||||
|
echo("")
|
||||||
|
echo("cancelled")
|
||||||
|
return None
|
||||||
|
piece, done = finish_triple_quote_line(line)
|
||||||
|
if done:
|
||||||
|
if piece:
|
||||||
|
parts.append(piece)
|
||||||
|
break
|
||||||
|
parts.append(piece)
|
||||||
|
text = "\n".join(parts)
|
||||||
|
return text if text.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _default_prompt() -> str:
|
||||||
|
return input("> ")
|
||||||
|
|
||||||
|
|
||||||
|
def _default_cont() -> str:
|
||||||
|
return input("... ")
|
||||||
|
|
||||||
|
|
||||||
|
def _default_echo(message: str) -> None:
|
||||||
|
click.echo(message)
|
||||||
|
|
||||||
|
|
||||||
|
def read_repl_entry(
|
||||||
|
*,
|
||||||
|
read_line: Callable[[], str] | None = None,
|
||||||
|
echo: Callable[[str], None] | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Read one REPL entry (slash line, single line, or multiline).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``None`` — empty line or cancelled multiline (caller should re-prompt).
|
||||||
|
``str`` starting with ``/`` — slash command line (stripped).
|
||||||
|
other ``str`` — user message text (may contain newlines).
|
||||||
|
"""
|
||||||
|
_read = read_line if read_line is not None else _default_prompt
|
||||||
|
_echo = echo if echo is not None else _default_echo
|
||||||
|
|
||||||
|
try:
|
||||||
|
first = _read()
|
||||||
|
except EOFError:
|
||||||
|
raise
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
_echo("")
|
||||||
|
return None
|
||||||
|
|
||||||
|
stripped = first.strip()
|
||||||
|
if not stripped:
|
||||||
|
return None
|
||||||
|
if stripped.startswith("/") and parse_triple_quote_line(stripped) is None:
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
if parse_triple_quote_line(first) is not None:
|
||||||
|
cont_read = read_line if read_line is not None else _default_cont
|
||||||
|
return assemble_multiline(first, read_line=cont_read, echo=_echo)
|
||||||
|
|
||||||
|
return stripped
|
||||||
+21
-14
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from plyngent.cli.input_text import read_repl_entry
|
||||||
from plyngent.cli.readline_setup import setup_readline
|
from plyngent.cli.readline_setup import setup_readline
|
||||||
from plyngent.cli.retry import run_user_text_with_retries
|
from plyngent.cli.retry import run_user_text_with_retries
|
||||||
from plyngent.cli.slash import handle_slash
|
from plyngent.cli.slash import handle_slash
|
||||||
@@ -12,9 +13,13 @@ if TYPE_CHECKING:
|
|||||||
from plyngent.cli.state import ReplState
|
from plyngent.cli.state import ReplState
|
||||||
|
|
||||||
|
|
||||||
def _read_line() -> str:
|
def _echo_user(text: str) -> None:
|
||||||
"""Blocking readline input (intentional for TTY REPL)."""
|
click.secho("user: ", fg="green", nl=False)
|
||||||
return input("> ").strip()
|
if "\n" in text:
|
||||||
|
click.echo()
|
||||||
|
click.echo(text)
|
||||||
|
else:
|
||||||
|
click.echo(text)
|
||||||
|
|
||||||
|
|
||||||
async def run_repl(state: ReplState) -> None:
|
async def run_repl(state: ReplState) -> None:
|
||||||
@@ -27,26 +32,28 @@ async def run_repl(state: ReplState) -> None:
|
|||||||
f"stream={'on' if state.agent.stream else 'off'} "
|
f"stream={'on' if state.agent.stream else 'off'} "
|
||||||
f"verbose={'on' if state.verbose else 'off'}"
|
f"verbose={'on' if state.verbose else 'off'}"
|
||||||
)
|
)
|
||||||
click.echo("Type /help for commands. Empty line is ignored.")
|
click.echo('Type /help for commands. Multiline: """ … """. Empty line is ignored.')
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
line = _read_line()
|
entry = read_repl_entry()
|
||||||
except EOFError:
|
except EOFError:
|
||||||
click.echo()
|
click.echo()
|
||||||
break
|
break
|
||||||
except KeyboardInterrupt:
|
|
||||||
click.echo()
|
if entry is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not line:
|
if entry.startswith("/"):
|
||||||
continue
|
cont = await handle_slash(state, entry)
|
||||||
if line.startswith("/"):
|
|
||||||
cont = await handle_slash(state, line)
|
|
||||||
if not cont:
|
if not cont:
|
||||||
break
|
break
|
||||||
|
if state.pending_user_text is not None:
|
||||||
|
text = state.pending_user_text
|
||||||
|
state.pending_user_text = None
|
||||||
|
_echo_user(text)
|
||||||
|
_ = await run_user_text_with_retries(state.agent, text)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
click.secho("user: ", fg="green", nl=False)
|
_echo_user(entry)
|
||||||
click.echo(line)
|
_ = await run_user_text_with_retries(state.agent, entry)
|
||||||
_ = await run_user_text_with_retries(state.agent, line)
|
|
||||||
|
|||||||
@@ -30,15 +30,18 @@ _COMPACT_PREVIEW = 400
|
|||||||
_ON_OFF_CHOICES = ("on", "off")
|
_ON_OFF_CHOICES = ("on", "off")
|
||||||
_EXPORT_FORMAT_CHOICES = ("md", "json")
|
_EXPORT_FORMAT_CHOICES = ("md", "json")
|
||||||
|
|
||||||
HELP_FOOTER = """\
|
HELP_FOOTER = (
|
||||||
User messages are saved immediately. On API errors or Ctrl+C, partial
|
"User messages are saved immediately. On API errors or Ctrl+C, partial\n"
|
||||||
assistant/tool output is discarded but the user message stays (so /retry
|
"assistant/tool output is discarded but the user message stays (so /retry\n"
|
||||||
works after resume, not only via readline history). Auto-retry: 10s/20s/30s.
|
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
|
||||||
|
"\n"
|
||||||
Tab completes slash commands and some arguments (provider, model, tools,
|
"Tab completes slash commands and some arguments (provider, model, tools,\n"
|
||||||
stream, verbose, export). Use --session ID or /resume to continue a prior
|
"stream, verbose, export). Use --session ID or /resume to continue a prior\n"
|
||||||
chat after restart.
|
"chat after restart.\n"
|
||||||
"""
|
"\n"
|
||||||
|
'Multiline: start a message with """ then end a later line with """.\n'
|
||||||
|
"Long prompts: /edit opens $EDITOR.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReplExitError(Exception):
|
class ReplExitError(Exception):
|
||||||
@@ -268,6 +271,28 @@ def clear_cmd(state: ReplState) -> None:
|
|||||||
click.echo("conversation cleared (in-memory only; DB history kept)")
|
click.echo("conversation cleared (in-memory only; DB history kept)")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("edit")
|
||||||
|
@click.pass_obj
|
||||||
|
def edit_cmd(state: ReplState) -> None:
|
||||||
|
"""Compose a user message in ``$EDITOR``, then send it.
|
||||||
|
|
||||||
|
Opens a temporary buffer; save and quit the editor to submit.
|
||||||
|
Empty buffer cancels. Requires ``EDITOR`` (e.g. ``codium --wait``).
|
||||||
|
"""
|
||||||
|
from plyngent.cli.editor import edit_text_in_editor
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = edit_text_in_editor("")
|
||||||
|
except click.ClickException as exc:
|
||||||
|
click.echo(f"error: {exc}")
|
||||||
|
return
|
||||||
|
if text is None:
|
||||||
|
click.echo("edit cancelled (empty)")
|
||||||
|
return
|
||||||
|
state.pending_user_text = text
|
||||||
|
click.echo(f"(edit) {len(text)} characters ready to send")
|
||||||
|
|
||||||
|
|
||||||
@slash.command("status")
|
@slash.command("status")
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def status_cmd(state: ReplState) -> None:
|
def status_cmd(state: ReplState) -> None:
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ class ReplState:
|
|||||||
max_rounds: int = DEFAULT_MAX_ROUNDS
|
max_rounds: int = DEFAULT_MAX_ROUNDS
|
||||||
stream_enabled: bool = True
|
stream_enabled: bool = True
|
||||||
verbose: bool = False
|
verbose: bool = False
|
||||||
|
# Set by /edit; REPL sends as the next user turn then clears.
|
||||||
|
pending_user_text: str | None = None
|
||||||
client: ChatClient = field(init=False)
|
client: ChatClient = field(init=False)
|
||||||
agent: ChatAgent = field(init=False)
|
agent: ChatAgent = field(init=False)
|
||||||
session_id: int | None = None
|
session_id: int | None = None
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
@@ -14,9 +14,6 @@ from plyngent.cli.editor import (
|
|||||||
resolve_config_path,
|
resolve_config_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_editor(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_get_editor(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.delenv("EDITOR", raising=False)
|
monkeypatch.delenv("EDITOR", raising=False)
|
||||||
@@ -66,6 +63,42 @@ def test_open_in_editor_missing_editor(tmp_path: Path, monkeypatch: pytest.Monke
|
|||||||
open_in_editor(tmp_path / "x.toml", editor=None)
|
open_in_editor(tmp_path / "x.toml", editor=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_text_in_editor(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
from plyngent.cli.editor import edit_text_in_editor
|
||||||
|
|
||||||
|
def fake_run(argv: list[str], check: bool = False) -> object:
|
||||||
|
del check
|
||||||
|
path = Path(argv[-1])
|
||||||
|
_ = path.write_text("hello from editor\n", encoding="utf-8")
|
||||||
|
|
||||||
|
class Result:
|
||||||
|
returncode: int = 0
|
||||||
|
|
||||||
|
return Result()
|
||||||
|
|
||||||
|
monkeypatch.setenv("EDITOR", "true")
|
||||||
|
monkeypatch.setattr("plyngent.cli.editor.subprocess.run", fake_run)
|
||||||
|
assert edit_text_in_editor("seed") == "hello from editor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_text_empty_cancels(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
from plyngent.cli.editor import edit_text_in_editor
|
||||||
|
|
||||||
|
def fake_run(argv: list[str], check: bool = False) -> object:
|
||||||
|
del check
|
||||||
|
path = Path(argv[-1])
|
||||||
|
_ = path.write_text(" \n", encoding="utf-8")
|
||||||
|
|
||||||
|
class Result:
|
||||||
|
returncode: int = 0
|
||||||
|
|
||||||
|
return Result()
|
||||||
|
|
||||||
|
monkeypatch.setenv("EDITOR", "true")
|
||||||
|
monkeypatch.setattr("plyngent.cli.editor.subprocess.run", fake_run)
|
||||||
|
assert edit_text_in_editor("") is None
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_edit_no_editor(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_prompt_edit_no_editor(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.delenv("EDITOR", raising=False)
|
monkeypatch.delenv("EDITOR", raising=False)
|
||||||
assert prompt_edit_config(tmp_path / "p.toml") is False
|
assert prompt_edit_config(tmp_path / "p.toml") is False
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from plyngent.cli.input_text import (
|
||||||
|
assemble_multiline,
|
||||||
|
finish_triple_quote_line,
|
||||||
|
parse_triple_quote_line,
|
||||||
|
read_repl_entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_same_line_block() -> None:
|
||||||
|
assert parse_triple_quote_line('"""hello"""') == ("hello", True)
|
||||||
|
assert parse_triple_quote_line(' """hi""" ') == ("hi", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_open_block() -> None:
|
||||||
|
assert parse_triple_quote_line('"""first') == ("first", False)
|
||||||
|
assert parse_triple_quote_line('"""') == ("", False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_not_block() -> None:
|
||||||
|
assert parse_triple_quote_line("hello") is None
|
||||||
|
assert parse_triple_quote_line("/help") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_finish_closer() -> None:
|
||||||
|
assert finish_triple_quote_line('"""') == ("", True)
|
||||||
|
assert finish_triple_quote_line('last line"""') == ("last line", True)
|
||||||
|
assert finish_triple_quote_line("middle") == ("middle", False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assemble_multiline() -> None:
|
||||||
|
lines = iter(["line two", '"""'])
|
||||||
|
text = assemble_multiline(
|
||||||
|
'"""line one',
|
||||||
|
read_line=lambda: next(lines),
|
||||||
|
)
|
||||||
|
assert text == "line one\nline two"
|
||||||
|
|
||||||
|
|
||||||
|
def test_assemble_empty_cancels() -> None:
|
||||||
|
lines = iter(['"""'])
|
||||||
|
assert assemble_multiline('"""', read_line=lambda: next(lines)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_repl_entry_slash() -> None:
|
||||||
|
assert read_repl_entry(read_line=lambda: "/status") == "/status"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_repl_entry_simple() -> None:
|
||||||
|
assert read_repl_entry(read_line=lambda: " hi ") == "hi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_repl_entry_empty() -> None:
|
||||||
|
assert read_repl_entry(read_line=lambda: " ") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_repl_entry_multiline() -> None:
|
||||||
|
seq = iter(['"""a', "b", '"""'])
|
||||||
|
text = read_repl_entry(read_line=lambda: next(seq), echo=lambda _s: None)
|
||||||
|
assert text == "a\nb"
|
||||||
@@ -134,6 +134,20 @@ async def test_quit(state: ReplState) -> None:
|
|||||||
assert await handle_slash(state, "/quit") is False
|
assert await handle_slash(state, "/quit") is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_edit_sets_pending(
|
||||||
|
state: ReplState,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"plyngent.cli.editor.edit_text_in_editor",
|
||||||
|
lambda *a, **k: "composed message",
|
||||||
|
)
|
||||||
|
assert await handle_slash(state, "/edit") is True
|
||||||
|
assert state.pending_user_text == "composed message"
|
||||||
|
assert "characters ready" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
async def test_new_and_sessions(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
async def test_new_and_sessions(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
first = state.session_id
|
first = state.session_id
|
||||||
assert await handle_slash(state, "/new other") is True
|
assert await handle_slash(state, "/new other") is True
|
||||||
|
|||||||
Reference in New Issue
Block a user