diff --git a/docs/cli.md b/docs/cli.md index f38373b..bda7889 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -7,3 +7,14 @@ python -m freeact.cli --help ``` or check [quickstart](quickstart.md) and [tutorials](tutorials/index.md) for usage examples. + +## Multiline input + +The `freeact` CLI supports entering messages that span multiple lines in two ways: + +1. **Copy-paste**: You can directly copy and paste multiline content into the CLI +2. **Manual entry**: Press `Alt+Enter` (Linux/Windows) or `Option+Enter` (macOS) to add a new line while typing + +To submit a multiline message, simply press `Enter`. + +![Multiline input](img/multiline.png) diff --git a/docs/img/multiline.png b/docs/img/multiline.png new file mode 100644 index 0000000..e98ce85 Binary files /dev/null and b/docs/img/multiline.png differ diff --git a/freeact/cli/utils.py b/freeact/cli/utils.py index 790f68c..bec9a61 100644 --- a/freeact/cli/utils.py +++ b/freeact/cli/utils.py @@ -1,14 +1,15 @@ +import platform from contextlib import asynccontextmanager from pathlib import Path from typing import Dict import aiofiles +import prompt_toolkit from dotenv import dotenv_values -from ipybox import arun from PIL import Image +from prompt_toolkit.key_binding import KeyBindings from rich.console import Console from rich.panel import Panel -from rich.prompt import Prompt from rich.rule import Rule from rich.syntax import Syntax from rich.text import Text @@ -51,18 +52,38 @@ async def execution_environment( async def stream_conversation(agent: CodeActAgent, console: Console, show_token_usage: bool = False, **kwargs): + "enter" empty_input = False + kb = KeyBindings() + + @kb.add("enter") + def _(event): + """Submit the input when Enter is pressed.""" + event.app.exit(result=event.app.current_buffer.text) + + @kb.add("escape", "enter") + def _(event): + """Insert a newline when Alt+Enter or Meta+Enter is pressed.""" + event.current_buffer.insert_text("\n") + + session = prompt_toolkit.PromptSession( + multiline=True, + key_bindings=kb, + ) + + escape_key = "Option" if platform.system() == "Darwin" else "Alt" + while True: console.print(Rule("User message", style="dodger_blue1", characters="━")) if empty_input: empty_input = False - prefix = "Please provide a non-empty message " + prefix = "Please enter a non-empty message" else: prefix = "" - user_message = await arun(Prompt.ask, f"{prefix}('q' to quit)", console=console) + user_message = await session.prompt_async(f"'q': quit, {escape_key}+Enter: newline\n\n{prefix}> ") if not user_message.strip(): empty_input = True diff --git a/mkdocs.yml b/mkdocs.yml index bc822ad..1767a2f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -88,7 +88,7 @@ nav: - Installation: installation.md - Building blocks: blocks.md - Supported models: models.md - - Command line interface: cli.md + - CLI: cli.md - Tutorials: - Overview: tutorials/index.md - Basic usage: tutorials/basics.md diff --git a/poetry.lock b/poetry.lock index c3af553..6dcef50 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "aioconsole" @@ -2063,6 +2063,20 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "prompt-toolkit" +version = "3.0.50" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198"}, + {file = "prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab"}, +] + +[package.dependencies] +wcwidth = "*" + [[package]] name = "propcache" version = "0.2.1" @@ -2933,6 +2947,17 @@ files = [ [package.extras] watchmedo = ["PyYAML (>=3.10)"] +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + [[package]] name = "websockets" version = "14.1" @@ -3242,4 +3267,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.11,<3.14" -content-hash = "c4b6e5d8aef1278c5d368b532ed676f75adbfe65dbea6cd1bc6b25b527932dac" +content-hash = "c79051e2648aa472eec2fb06afe528c1656abdd2e467fabc90a37edef7373394" diff --git a/pyproject.toml b/pyproject.toml index 663aba0..1a24573 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ anthropic = "^0.43.0" google-genai = "^0.3.0" ipybox = "^0.3.1" openai = "^1.59" +prompt_toolkit = "^3.0" python = "^3.11,<3.14" python-dotenv = "^1.0" rich = "^13.9"