Newer
Older
navi-1 / clients / terminal / cli.py
"""Click CLI for the Navi Code terminal client."""

from __future__ import annotations

import asyncio

import click

from clients.terminal import api
from clients.terminal.config import settings
from clients.terminal.render import Renderer
from clients.terminal.state import StateManager
from clients.terminal.ws_client import NaviWebSocketClient


@click.command(context_settings={"ignore_unknown_options": True, "allow_extra_args": True})
@click.argument("prompt", required=False)
@click.option("--base-url", default=None, help="Navi API base URL.")
@click.option("--ws-url", default=None, help="Navi WebSocket URL (defaults to base-url).")
@click.option("--profile-id", default=None, help="Profile to use for new sessions.")
@click.option("--new-session", is_flag=True, help="Create a new session even if state exists.")
@click.option("--show-thinking", is_flag=True, help="Show model reasoning blocks.")
@click.option("--show-events/--no-events", default=True, help="Show tool call events.")
@click.option("--theme", default=None, help="TUI theme name to start with.")
@click.option("--raw", is_flag=True, help="Use the plain CLI instead of the TUI.")
@click.version_option(version="0.1.0", prog_name="navi-code")
def main(
    prompt: str | None,
    base_url: str | None,
    ws_url: str | None,
    profile_id: str | None,
    new_session: bool,
    show_thinking: bool,
    show_events: bool,
    theme: str | None,
    raw: bool,
) -> None:
    """Navi Code — terminal client for Navi.

    Without PROMPT, runs the TUI. With PROMPT, sends one message and exits.
    Use --raw to run the plain CLI instead of the TUI.
    """
    if base_url:
        settings.base_url = base_url
    if ws_url:
        settings.ws_url = ws_url
    if show_thinking:
        settings.show_thinking = True
    settings.show_events = show_events

    if raw or prompt:
        _run_raw(prompt, new_session, profile_id)
        return

    _run_tui(profile_id, new_session, theme)


def _run_raw(prompt: str | None, new_session: bool, profile_id: str | None) -> None:
    state = StateManager()
    session_id = _resolve_session_id(state, new_session, profile_id)
    if not session_id:
        raise click.ClickException("Failed to create or resume a session.")

    renderer = Renderer(show_thinking=settings.show_thinking, show_events=settings.show_events)
    client = NaviWebSocketClient(session_id, renderer=renderer)

    if prompt:
        asyncio.run(_run_one_shot(client, prompt))
        return

    asyncio.run(_run_interactive(client, state))


def _run_tui(profile_id: str | None, new_session: bool, theme: str | None) -> None:
    from clients.terminal.tui.tui_app import NaviCodeTui

    app = NaviCodeTui(profile_id=profile_id, new_session=new_session, theme_name=theme)
    app.run(mouse=app._mouse_enabled)


def _resolve_session_id(state: StateManager, force_new: bool, profile_id: str | None) -> str | None:
    if not force_new:
        saved = state.get_session_id()
        if saved:
            try:
                session = api.get_session(saved)
                click.secho(
                    f"Resumed session {session['id'][:8]} (profile {session['profile_id']})",
                    fg="bright_black",
                )
                return session["id"]
            except Exception:
                state.clear_session_id()

    profile = profile_id or settings.default_profile_id
    try:
        session = api.create_session(profile)
    except Exception as exc:
        click.secho(f"Failed to create session: {exc}", fg="red", err=True)
        return None

    state.set_session_id(session["id"])
    click.secho(
        f"Created session {session['id'][:8]} (profile {session['profile_id']})",
        fg="green",
    )
    return session["id"]


async def _run_one_shot(client: NaviWebSocketClient, prompt: str) -> None:
    await client.run_one_shot(prompt)


async def _run_interactive(client: NaviWebSocketClient, state: StateManager) -> None:
    click.secho("Navi Code interactive mode. Type /quit to exit, /help for commands.", fg="cyan")

    await client.connect()
    receive_task = asyncio.create_task(client.receive_loop())

    try:
        while True:
            try:
                user_input = await asyncio.get_event_loop().run_in_executor(
                    None, lambda: input(click.style("You: ", fg="blue", bold=True)),
                )
            except EOFError:
                break

            user_input = user_input.strip()
            if not user_input:
                continue

            if user_input.startswith("/"):
                handled = await _handle_command(user_input, state, client)
                if handled:
                    continue

            client.enqueue(user_input)
    finally:
        client.stop_input()
        receive_task.cancel()
        try:
            await receive_task
        except asyncio.CancelledError:
            pass
        await client.close()


async def _handle_command(cmd: str, state: StateManager, client: NaviWebSocketClient) -> bool:
    parts = cmd.split()
    head = parts[0].lower()

    if head == "/quit":
        raise click.exceptions.Exit(0)

    if head == "/help":
        click.echo("Commands:")
        click.echo("  /new              create a new session")
        click.echo("  /sessions         list server sessions")
        click.echo("  /switch <id>      switch to another session")
        click.echo("  /profile          show current session profile")
        click.echo("  /clear            clear local session state")
        click.echo("  /quit             exit")
        return True

    if head == "/new":
        try:
            session = api.create_session(settings.default_profile_id)
        except Exception as exc:
            click.secho(f"Failed to create session: {exc}", fg="red", err=True)
            return True
        state.set_session_id(session["id"])
        click.secho(
            f"Switched to new session {session['id'][:8]} (profile {session['profile_id']})",
            fg="green",
        )
        return True

    if head == "/sessions":
        try:
            sessions = api.list_sessions()
        except Exception as exc:
            click.secho(f"Failed to list sessions: {exc}", fg="red", err=True)
            return True
        for s in sessions:
            click.echo(f"  {s['id'][:8]}  {s.get('profile_id', 'unknown')}  {s.get('title', '')}")
        return True

    if head == "/switch" and len(parts) == 2:
        target = parts[1]
        try:
            session = api.get_session(target)
        except Exception as exc:
            # Try to find by prefix
            try:
                sessions = api.list_sessions()
                matches = [s for s in sessions if s["id"].startswith(target)]
                if len(matches) == 1:
                    session = matches[0]
                else:
                    raise exc
            except Exception:
                click.secho(f"Session not found: {target}", fg="red", err=True)
                return True
        state.set_session_id(session["id"])
        click.secho(
            f"Switched to session {session['id'][:8]} (profile {session['profile_id']})",
            fg="green",
        )
        return True

    if head == "/profile":
        try:
            current = api.get_session(state.get_session_id() or "")
            click.echo(f"Profile: {current.get('profile_id', 'unknown')}")
            click.echo(f"Session: {current['id']}")
        except Exception as exc:
            click.secho(f"Failed to get session: {exc}", fg="red", err=True)
        return True

    if head == "/clear":
        state.clear_session_id()
        click.secho("Local session state cleared.", fg="green")
        return True

    return False


if __name__ == "__main__":
    main()