feat: replace REPL loop with Textual TUI launch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 12:48:10 -05:00
parent 99a15cbd9b
commit a15e428af0

View File

@@ -1,39 +1,19 @@
"""SneakyCode entrypoint — argument parsing, config loading, and interactive REPL."""
"""SneakyCode entrypoint — argument parsing, config loading, and TUI launch."""
import argparse
import asyncio
import signal
import sys
from pathlib import Path
import structlog
from app.agent.context import SessionContext
from app.agent.loop import AgentLoop
from app.models.config import AppConfig, load_config
from app.services.llm import LLMClient, LLMConnectionError, LLMError
from app.services.permissions import PermissionsService
from app.services.session import SessionManager
from app.services.streaming import StreamHandler
from app.tools.registry import create_default_registry
from app.utils.display import (
print_banner,
print_error,
print_history,
print_info,
print_success,
print_user_message,
print_warning,
)
from app.utils.logging import console, get_logger, setup_logging
from app.utils.display import print_banner, print_error, print_info, print_success
from app.utils.logging import get_logger, setup_logging
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments.
Returns:
Parsed arguments namespace.
"""
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
prog="sneakycode",
description="SneakyCode — A privacy-first local AI coding agent",
@@ -65,127 +45,11 @@ async def _preflight(config: AppConfig) -> None:
await client.preflight_check()
def _offer_session_resume(
session_mgr: SessionManager, ctx: SessionContext
) -> bool:
"""Check for a saved session and offer to resume it.
Args:
session_mgr: Session manager instance.
ctx: Session context to restore into.
Returns:
True if a session was restored.
"""
saved = session_mgr.load_latest()
if saved is None:
return False
msg_count = len(saved.messages)
print_info(f"Found previous session ({msg_count} messages, model: {saved.model})")
try:
answer = console.input("[bold cyan]Resume previous session? [y/N] [/bold cyan]").strip().lower()
except (KeyboardInterrupt, EOFError):
return False
if answer in ("y", "yes"):
session_mgr.restore(saved, ctx)
print_success(f"Session restored ({msg_count} messages).")
return True
return False
def _save_session_quiet(session_mgr: SessionManager, ctx: SessionContext) -> None:
"""Save session without raising on errors."""
try:
if ctx.message_count > 0:
session_mgr.save(ctx)
except OSError as e:
print_warning(f"Could not save session: {e}")
async def _run_repl(
ctx: SessionContext,
config: AppConfig,
session_mgr: SessionManager,
logger: structlog.stdlib.BoundLogger,
shutdown_event: asyncio.Event,
) -> None:
"""Run the interactive REPL loop with streaming LLM responses.
Args:
ctx: Session context for conversation state.
config: Application configuration.
session_mgr: Session manager for auto-save.
logger: Structured logger instance.
shutdown_event: Event signalling graceful shutdown.
"""
registry = create_default_registry(config.agent.workspace_root, config)
permissions = PermissionsService(config.permissions)
async with LLMClient(config.llm) as client:
handler = StreamHandler(config.display)
agent = AgentLoop(config, ctx, client, handler, registry, permissions)
while not shutdown_event.is_set():
try:
user_input = console.input("[bold cyan]> [/bold cyan]")
except (KeyboardInterrupt, EOFError):
_save_session_quiet(session_mgr, ctx)
console.print("\n[dim]Session saved. Goodbye![/dim]")
break
user_input = user_input.strip()
if not user_input:
continue
# Handle slash commands
if user_input.startswith("/"):
command = user_input.lower()
if command == "/quit":
_save_session_quiet(session_mgr, ctx)
console.print("[dim]Session saved. Goodbye![/dim]")
break
elif command == "/history":
print_history(ctx.get_history())
elif command == "/clear":
ctx.clear_history()
print_success("Conversation history cleared.")
elif command == "/save":
try:
path = session_mgr.save(ctx)
print_success(f"Session saved to {path}")
except OSError as e:
print_error(f"Failed to save session: {e}")
elif command == "/session":
print_info(f"Messages: {ctx.message_count}")
print_info(f"Tokens: ~{ctx.estimated_tokens:,} / {ctx.token_counter.budget:,}")
print_info(f"Started: {ctx.start_time.isoformat()}")
else:
print_warning(f"Unknown command: {user_input}")
continue
print_user_message(user_input)
await agent.run_turn(user_input)
logger.debug("turn_complete", message_count=ctx.message_count)
# Auto-save after each turn
if config.session.auto_save:
_save_session_quiet(session_mgr, ctx)
# Shutdown triggered by signal
if shutdown_event.is_set():
_save_session_quiet(session_mgr, ctx)
console.print("\n[dim]Session saved. Shutting down gracefully.[/dim]")
def main() -> None:
"""Main entrypoint: load config, setup logging, launch interactive REPL."""
"""Main entrypoint: load config, preflight check, launch Textual TUI."""
args = parse_args()
# Setup logging first
# Setup logging first (will be reconfigured for TUI on mount)
setup_logging(
log_file=args.log_file,
verbose=args.verbose,
@@ -201,7 +65,7 @@ def main() -> None:
logger.info("config_loaded", model=config.llm.model, endpoint=config.llm.endpoint)
# Print startup info
# Pre-TUI startup info (printed to console before Textual takes over)
print_banner()
print_info(f"Model: {config.llm.model}")
print_info(f"Endpoint: {config.llm.endpoint}")
@@ -222,8 +86,7 @@ def main() -> None:
print_success("Ollama connected, model ready.")
# Create session and session manager
ctx = SessionContext(config)
# Create session manager
session_mgr = SessionManager(config.session, config.agent.workspace_root, config.llm.model)
# Clean up old session files
@@ -231,28 +94,11 @@ def main() -> None:
if cleaned > 0:
logger.info("old_sessions_cleaned", count=cleaned)
# Offer to resume previous session
if config.session.offer_resume:
_offer_session_resume(session_mgr, ctx)
# Launch Textual TUI
from app.ui.app import SneakyCodeApp
logger.info("startup_complete")
# Setup shutdown event and SIGTERM handler
shutdown_event = asyncio.Event()
original_sigterm = signal.getsignal(signal.SIGTERM)
def _sigterm_handler(signum: int, frame: object) -> None:
shutdown_event.set()
signal.signal(signal.SIGTERM, _sigterm_handler)
print_info("Commands: /quit, /history, /clear, /save, /session")
try:
asyncio.run(_run_repl(ctx, config, session_mgr, logger, shutdown_event))
finally:
signal.signal(signal.SIGTERM, original_sigterm)
app = SneakyCodeApp(config, session_mgr=session_mgr)
app.run()
if __name__ == "__main__":