feat: REPL mode, stdin support, multi-word fix for CLI (#26)

This commit is contained in:
2026-03-14 19:25:10 -04:00
parent 9134ce2f71
commit 65e5e7786f
2 changed files with 251 additions and 4 deletions

View File

@@ -1,3 +1,4 @@
import asyncio
import logging
import subprocess
import sys
@@ -143,7 +144,9 @@ def think(
@app.command()
def chat(
message: list[str] = typer.Argument(..., help="Message to send to Timmy"),
message: list[str] = typer.Argument(
..., help="Message to send (multiple words are joined automatically)"
),
backend: str | None = _BACKEND_OPTION,
model_size: str | None = _MODEL_SIZE_OPTION,
new_session: bool = typer.Option(
@@ -172,11 +175,26 @@ def chat(
Use --autonomous for non-interactive contexts (scripts, dev loops). Tool
calls are checked against config/allowlist.yaml — allowlisted operations
execute automatically, everything else is safely rejected.
"""
full_message = " ".join(message)
Read from stdin by passing "-" as the message or piping input.
"""
import uuid
# Join multiple arguments into a single message string
message_str = " ".join(message)
# Handle stdin input if "-" is passed or stdin is not a tty
if message_str == "-" or not _is_interactive():
try:
stdin_content = sys.stdin.read().strip()
except (KeyboardInterrupt, EOFError):
stdin_content = ""
if stdin_content:
message_str = stdin_content
elif message_str == "-":
typer.echo("No input provided via stdin.", err=True)
raise typer.Exit(1)
if session_id is not None:
pass # use the provided value
elif new_session:
@@ -186,7 +204,7 @@ def chat(
timmy = create_timmy(backend=backend, model_size=model_size, session_id=session_id)
# Use agent.run() so we can intercept paused runs for tool confirmation.
run_output = timmy.run(full_message, stream=False, session_id=session_id)
run_output = timmy.run(message_str, stream=False, session_id=session_id)
# Handle paused runs — dangerous tools need user approval
run_output = _handle_tool_confirmation(timmy, run_output, session_id, autonomous=autonomous)
@@ -199,6 +217,61 @@ def chat(
typer.echo(_clean_response(content))
@app.command()
def repl(
backend: str | None = _BACKEND_OPTION,
model_size: str | None = _MODEL_SIZE_OPTION,
session_id: str | None = typer.Option(
None,
"--session-id",
help="Use a specific session ID for this conversation",
),
):
"""Start an interactive REPL session with Timmy.
Keeps the agent warm between messages. Conversation history is persisted
across invocations. Use Ctrl+C or Ctrl+D to exit gracefully.
"""
from timmy.session import chat
if session_id is None:
session_id = _CLI_SESSION_ID
typer.echo(typer.style("Timmy REPL", bold=True))
typer.echo("Type your messages below. Use Ctrl+C or Ctrl+D to exit.")
typer.echo()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
while True:
try:
user_input = input("> ")
except (KeyboardInterrupt, EOFError):
typer.echo()
typer.echo("Goodbye!")
break
user_input = user_input.strip()
if not user_input:
continue
if user_input.lower() in ("exit", "quit", "q"):
typer.echo("Goodbye!")
break
try:
response = loop.run_until_complete(chat(user_input, session_id=session_id))
if response:
typer.echo(response)
typer.echo()
except Exception as exc:
typer.echo(f"Error: {exc}", err=True)
finally:
loop.close()
@app.command()
def status(
backend: str | None = _BACKEND_OPTION,