virtuai-cli 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/PKG-INFO +3 -1
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/pyproject.toml +3 -1
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli/__init__.py +1 -1
- virtuai_cli-0.2.0/src/virtuai_cli/chat/__init__.py +4 -0
- virtuai_cli-0.2.0/src/virtuai_cli/chat/command.py +97 -0
- virtuai_cli-0.2.0/src/virtuai_cli/chat/sse.py +51 -0
- virtuai_cli-0.2.0/src/virtuai_cli/chat/tui.py +244 -0
- virtuai_cli-0.2.0/src/virtuai_cli/chat/widgets.py +164 -0
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli/executor.py +22 -1
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli/main.py +11 -0
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli/runner.py +38 -12
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli.egg-info/PKG-INFO +3 -1
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli.egg-info/SOURCES.txt +6 -1
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli.egg-info/requires.txt +2 -0
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/README.md +0 -0
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/setup.cfg +0 -0
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: virtuai-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Run VirtuAI deep agents on your local machine
|
|
5
5
|
Author-email: uCloudStore <lmoreno@ucloudstore.com>
|
|
6
6
|
License: Proprietary
|
|
@@ -14,10 +14,12 @@ Requires-Python: >=3.11
|
|
|
14
14
|
Description-Content-Type: text/markdown
|
|
15
15
|
Requires-Dist: websockets>=12.0
|
|
16
16
|
Requires-Dist: httpx[http2]>=0.27
|
|
17
|
+
Requires-Dist: httpx-sse>=0.4
|
|
17
18
|
Requires-Dist: certifi>=2024.0
|
|
18
19
|
Requires-Dist: keyring>=25.0
|
|
19
20
|
Requires-Dist: typer>=0.12
|
|
20
21
|
Requires-Dist: rich>=13.0
|
|
22
|
+
Requires-Dist: textual>=0.50
|
|
21
23
|
|
|
22
24
|
# VirtuAI CLI
|
|
23
25
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "virtuai-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Run VirtuAI deep agents on your local machine"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -22,10 +22,12 @@ classifiers = [
|
|
|
22
22
|
dependencies = [
|
|
23
23
|
"websockets>=12.0",
|
|
24
24
|
"httpx[http2]>=0.27",
|
|
25
|
+
"httpx-sse>=0.4",
|
|
25
26
|
"certifi>=2024.0",
|
|
26
27
|
"keyring>=25.0",
|
|
27
28
|
"typer>=0.12",
|
|
28
29
|
"rich>=13.0",
|
|
30
|
+
"textual>=0.50",
|
|
29
31
|
]
|
|
30
32
|
|
|
31
33
|
[project.urls]
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""VirtuAI local CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Entry point for `virtuai chat` — wires auth + agent picker + TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import certifi
|
|
9
|
+
import httpx
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from virtuai_cli import config as cfg
|
|
15
|
+
from virtuai_cli.chat.tui import ChatApp
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
_HTTPX_KWARGS = {"verify": certifi.where()}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fetch_agents(server_url: str, token: str) -> dict:
|
|
23
|
+
resp = httpx.get(
|
|
24
|
+
f"{server_url.rstrip('/')}/api/cli/agents",
|
|
25
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
26
|
+
timeout=15,
|
|
27
|
+
**_HTTPX_KWARGS,
|
|
28
|
+
)
|
|
29
|
+
if resp.status_code == 401:
|
|
30
|
+
raise typer.BadParameter("CLI token is invalid or revoked — run `virtuai pair <code>` again.")
|
|
31
|
+
if resp.status_code == 404:
|
|
32
|
+
raise typer.BadParameter("Workspace not available — run `virtuai pair <code>` again.")
|
|
33
|
+
resp.raise_for_status()
|
|
34
|
+
return resp.json()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _pick_agent(agents: list[dict], requested: Optional[str]) -> dict:
|
|
38
|
+
if not agents:
|
|
39
|
+
raise typer.BadParameter("No chat-enabled agents are available in this workspace.")
|
|
40
|
+
|
|
41
|
+
if requested:
|
|
42
|
+
for a in agents:
|
|
43
|
+
if a["agent_id"] == requested or a["name"] == requested:
|
|
44
|
+
return a
|
|
45
|
+
raise typer.BadParameter(f"Agent '{requested}' not found in this workspace.")
|
|
46
|
+
|
|
47
|
+
if len(agents) == 1:
|
|
48
|
+
return agents[0]
|
|
49
|
+
|
|
50
|
+
table = Table(show_header=True, header_style="bold")
|
|
51
|
+
table.add_column("#", style="cyan", width=4)
|
|
52
|
+
table.add_column("Agent")
|
|
53
|
+
table.add_column("Model", style="dim")
|
|
54
|
+
for i, a in enumerate(agents, start=1):
|
|
55
|
+
table.add_row(str(i), a["name"], a.get("default_model_id") or "")
|
|
56
|
+
console.print(table)
|
|
57
|
+
choice = typer.prompt("Pick an agent by number", default="1")
|
|
58
|
+
try:
|
|
59
|
+
idx = int(choice) - 1
|
|
60
|
+
return agents[idx]
|
|
61
|
+
except (ValueError, IndexError):
|
|
62
|
+
raise typer.BadParameter(f"Invalid choice: {choice}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_chat(
|
|
66
|
+
workdir: Optional[Path],
|
|
67
|
+
server: Optional[str],
|
|
68
|
+
agent: Optional[str],
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Launch the chat TUI. Called by the `chat` command in main.py."""
|
|
71
|
+
server_url = (server or cfg.get_server_url()).rstrip("/")
|
|
72
|
+
token = cfg.load_token(cfg._KEYRING_CLI_TOKEN_KEY)
|
|
73
|
+
if not token:
|
|
74
|
+
console.print("[red]Not paired.[/red] Run [bold]virtuai pair <code>[/bold] first.")
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
|
|
77
|
+
base = workdir or Path.cwd()
|
|
78
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
info = _fetch_agents(server_url, token)
|
|
82
|
+
except httpx.HTTPError as exc:
|
|
83
|
+
console.print(f"[red]Could not reach server:[/red] {exc}")
|
|
84
|
+
raise typer.Exit(1)
|
|
85
|
+
|
|
86
|
+
picked = _pick_agent(info["agents"], agent)
|
|
87
|
+
|
|
88
|
+
app = ChatApp(
|
|
89
|
+
server_url=server_url,
|
|
90
|
+
token=token,
|
|
91
|
+
workdir=base,
|
|
92
|
+
agent_id=picked["agent_id"],
|
|
93
|
+
agent_name=picked["name"],
|
|
94
|
+
workspace_name=info["workspace_name"],
|
|
95
|
+
default_model_id=picked.get("default_model_id"),
|
|
96
|
+
)
|
|
97
|
+
app.run()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Async SSE chat client — POSTs a user message and yields parsed events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import ssl
|
|
7
|
+
from typing import AsyncIterator, Optional
|
|
8
|
+
|
|
9
|
+
import certifi
|
|
10
|
+
import httpx
|
|
11
|
+
from httpx_sse import aconnect_sse
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _ssl_context() -> ssl.SSLContext:
|
|
15
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def stream_chat(
|
|
19
|
+
server_url: str,
|
|
20
|
+
token: str,
|
|
21
|
+
agent_id: str,
|
|
22
|
+
message: str,
|
|
23
|
+
session_id: Optional[str] = None,
|
|
24
|
+
model_id: Optional[str] = None,
|
|
25
|
+
timeout: float = 600.0,
|
|
26
|
+
) -> AsyncIterator[dict]:
|
|
27
|
+
"""Open an SSE chat stream and yield each event as a parsed dict.
|
|
28
|
+
|
|
29
|
+
The CLI token is passed as `Authorization: Bearer <token>`; the chat
|
|
30
|
+
principal now accepts CLI tokens (see web_chat/deps.py).
|
|
31
|
+
"""
|
|
32
|
+
payload: dict = {"message": message}
|
|
33
|
+
if session_id:
|
|
34
|
+
payload["session_id"] = session_id
|
|
35
|
+
if model_id:
|
|
36
|
+
payload["model_id"] = model_id
|
|
37
|
+
|
|
38
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
39
|
+
url = f"{server_url.rstrip('/')}/api/web-chat/chat/{agent_id}/stream"
|
|
40
|
+
verify = certifi.where()
|
|
41
|
+
|
|
42
|
+
async with httpx.AsyncClient(verify=verify, timeout=httpx.Timeout(timeout, connect=15.0)) as client:
|
|
43
|
+
async with aconnect_sse(client, "POST", url, json=payload, headers=headers) as event_source:
|
|
44
|
+
async for sse in event_source.aiter_sse():
|
|
45
|
+
if not sse.data:
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
yield json.loads(sse.data)
|
|
49
|
+
except json.JSONDecodeError:
|
|
50
|
+
# Server occasionally emits non-JSON keepalive lines; skip.
|
|
51
|
+
continue
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Textual application — Claude-Code-style chat over the VirtuAI agent stream."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from textual import on
|
|
10
|
+
from textual.app import App, ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Vertical, VerticalScroll
|
|
13
|
+
from textual.reactive import reactive
|
|
14
|
+
from textual.widgets import Footer, Header, Input, Static
|
|
15
|
+
|
|
16
|
+
from virtuai_cli import runner as ws_runner
|
|
17
|
+
from virtuai_cli.chat.sse import stream_chat
|
|
18
|
+
from virtuai_cli.chat.widgets import AssistantTurn, UserBubble
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChatApp(App):
|
|
22
|
+
CSS = """
|
|
23
|
+
Screen { background: $surface; }
|
|
24
|
+
|
|
25
|
+
#conversation {
|
|
26
|
+
height: 1fr;
|
|
27
|
+
padding: 0 1;
|
|
28
|
+
scrollbar-gutter: stable;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#status {
|
|
32
|
+
dock: top;
|
|
33
|
+
height: 1;
|
|
34
|
+
padding: 0 1;
|
|
35
|
+
background: $panel;
|
|
36
|
+
color: $text-muted;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#input {
|
|
40
|
+
dock: bottom;
|
|
41
|
+
height: 3;
|
|
42
|
+
border: round $primary;
|
|
43
|
+
margin: 0 1 1 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#placeholder {
|
|
47
|
+
margin: 1 2;
|
|
48
|
+
color: $text-muted;
|
|
49
|
+
}
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
BINDINGS = [
|
|
53
|
+
Binding("escape", "cancel_stream", "Cancel", show=True),
|
|
54
|
+
Binding("ctrl+c", "quit", "Quit", show=True),
|
|
55
|
+
Binding("ctrl+l", "clear_conversation", "New chat", show=True),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
status_line = reactive("")
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
server_url: str,
|
|
63
|
+
token: str,
|
|
64
|
+
workdir: Path,
|
|
65
|
+
agent_id: str,
|
|
66
|
+
agent_name: str,
|
|
67
|
+
workspace_name: str,
|
|
68
|
+
default_model_id: Optional[str] = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
super().__init__()
|
|
71
|
+
self.server_url = server_url
|
|
72
|
+
self.token = token
|
|
73
|
+
self.workdir = workdir
|
|
74
|
+
self.agent_id = agent_id
|
|
75
|
+
self.agent_name = agent_name
|
|
76
|
+
self.workspace_name = workspace_name
|
|
77
|
+
self.default_model_id = default_model_id
|
|
78
|
+
|
|
79
|
+
self.session_id: Optional[str] = None
|
|
80
|
+
self._stream_task: Optional[asyncio.Task] = None
|
|
81
|
+
self._runner_task: Optional[asyncio.Task] = None
|
|
82
|
+
self._current_turn: Optional[AssistantTurn] = None
|
|
83
|
+
|
|
84
|
+
# ── Layout ────────────────────────────────────────────────────────────
|
|
85
|
+
def compose(self) -> ComposeResult:
|
|
86
|
+
yield Header(show_clock=False)
|
|
87
|
+
yield Static(self._initial_status(), id="status")
|
|
88
|
+
with VerticalScroll(id="conversation"):
|
|
89
|
+
yield Static(
|
|
90
|
+
f"[b]{self.agent_name}[/b] in {self.workspace_name}\n"
|
|
91
|
+
f"Workdir: {self.workdir}\n"
|
|
92
|
+
f"[dim]Type a message and press Enter. Esc cancels. /help for commands.[/dim]",
|
|
93
|
+
id="placeholder",
|
|
94
|
+
)
|
|
95
|
+
yield Input(placeholder="Message…", id="input")
|
|
96
|
+
yield Footer()
|
|
97
|
+
|
|
98
|
+
def _initial_status(self) -> str:
|
|
99
|
+
model = f" · {self.default_model_id}" if self.default_model_id else ""
|
|
100
|
+
return f"{self.workspace_name} · {self.agent_name}{model} · connecting…"
|
|
101
|
+
|
|
102
|
+
# ── Mount: start the WS runner in the background ──────────────────────
|
|
103
|
+
async def on_mount(self) -> None:
|
|
104
|
+
self.title = "VirtuAI"
|
|
105
|
+
self.sub_title = f"{self.agent_name} · {self.workspace_name}"
|
|
106
|
+
self.query_one(Input).focus()
|
|
107
|
+
self._runner_task = asyncio.create_task(self._run_ws())
|
|
108
|
+
|
|
109
|
+
async def _run_ws(self) -> None:
|
|
110
|
+
def on_status(level: str, msg: str) -> None:
|
|
111
|
+
# Called from the runner; mutate the status bar safely.
|
|
112
|
+
self.call_from_thread(self._set_status, f"{msg}")
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
await ws_runner.run_forever(self.server_url, self.token, self.workdir, on_status=on_status)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
self.call_from_thread(self._set_status, f"runner error: {exc}")
|
|
118
|
+
|
|
119
|
+
def _set_status(self, text: str) -> None:
|
|
120
|
+
self.query_one("#status", Static).update(
|
|
121
|
+
f"{self.workspace_name} · {self.agent_name} · {text}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# ── Input submission ──────────────────────────────────────────────────
|
|
125
|
+
@on(Input.Submitted, "#input")
|
|
126
|
+
async def _on_submit(self, event: Input.Submitted) -> None:
|
|
127
|
+
text = event.value.strip()
|
|
128
|
+
if not text:
|
|
129
|
+
return
|
|
130
|
+
event.input.value = ""
|
|
131
|
+
|
|
132
|
+
if text.startswith("/"):
|
|
133
|
+
await self._handle_slash(text)
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
if self._stream_task and not self._stream_task.done():
|
|
137
|
+
self._set_status("a response is already streaming — press Esc to cancel first")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
await self._append(UserBubble(text))
|
|
141
|
+
turn = AssistantTurn()
|
|
142
|
+
await self._append(turn)
|
|
143
|
+
self._current_turn = turn
|
|
144
|
+
self._stream_task = asyncio.create_task(self._stream_response(text, turn))
|
|
145
|
+
|
|
146
|
+
async def _append(self, widget) -> None:
|
|
147
|
+
ph = self.query("#placeholder")
|
|
148
|
+
for p in ph:
|
|
149
|
+
await p.remove()
|
|
150
|
+
scroll = self.query_one("#conversation", VerticalScroll)
|
|
151
|
+
await scroll.mount(widget)
|
|
152
|
+
scroll.scroll_end(animate=False)
|
|
153
|
+
|
|
154
|
+
# ── Slash commands ────────────────────────────────────────────────────
|
|
155
|
+
async def _handle_slash(self, cmd: str) -> None:
|
|
156
|
+
parts = cmd.split(None, 1)
|
|
157
|
+
head = parts[0].lower()
|
|
158
|
+
if head in ("/exit", "/quit"):
|
|
159
|
+
self.exit()
|
|
160
|
+
elif head in ("/clear", "/new"):
|
|
161
|
+
await self.action_clear_conversation()
|
|
162
|
+
elif head == "/help":
|
|
163
|
+
await self._append(Static(
|
|
164
|
+
"[b]Commands[/b]\n"
|
|
165
|
+
" /help this list\n"
|
|
166
|
+
" /clear, /new start a fresh conversation\n"
|
|
167
|
+
" /exit, /quit close the TUI\n"
|
|
168
|
+
" Esc cancel current response\n"
|
|
169
|
+
))
|
|
170
|
+
else:
|
|
171
|
+
await self._append(Static(f"[red]Unknown command: {head}[/red]"))
|
|
172
|
+
|
|
173
|
+
async def action_clear_conversation(self) -> None:
|
|
174
|
+
self.session_id = None
|
|
175
|
+
self._current_turn = None
|
|
176
|
+
scroll = self.query_one("#conversation", VerticalScroll)
|
|
177
|
+
for child in list(scroll.children):
|
|
178
|
+
await child.remove()
|
|
179
|
+
await scroll.mount(Static(
|
|
180
|
+
"[dim]New conversation. Type a message and press Enter.[/dim]",
|
|
181
|
+
id="placeholder",
|
|
182
|
+
))
|
|
183
|
+
|
|
184
|
+
def action_cancel_stream(self) -> None:
|
|
185
|
+
if self._stream_task and not self._stream_task.done():
|
|
186
|
+
self._stream_task.cancel()
|
|
187
|
+
self._set_status("response cancelled")
|
|
188
|
+
|
|
189
|
+
# ── SSE stream → widget mutations ─────────────────────────────────────
|
|
190
|
+
async def _stream_response(self, message: str, turn: AssistantTurn) -> None:
|
|
191
|
+
try:
|
|
192
|
+
async for event in stream_chat(
|
|
193
|
+
self.server_url,
|
|
194
|
+
self.token,
|
|
195
|
+
self.agent_id,
|
|
196
|
+
message,
|
|
197
|
+
session_id=self.session_id,
|
|
198
|
+
model_id=self.default_model_id,
|
|
199
|
+
):
|
|
200
|
+
await self._handle_event(event, turn)
|
|
201
|
+
except asyncio.CancelledError:
|
|
202
|
+
self._set_status("response cancelled")
|
|
203
|
+
raise
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
await turn.append_token(f"\n\n*[error: {exc}]*")
|
|
206
|
+
self._set_status(f"stream error: {exc}")
|
|
207
|
+
finally:
|
|
208
|
+
turn.mark_final()
|
|
209
|
+
scroll = self.query_one("#conversation", VerticalScroll)
|
|
210
|
+
scroll.scroll_end(animate=False)
|
|
211
|
+
|
|
212
|
+
async def _handle_event(self, event: dict, turn: AssistantTurn) -> None:
|
|
213
|
+
etype = event.get("type")
|
|
214
|
+
if etype == "start":
|
|
215
|
+
sid = event.get("session_id")
|
|
216
|
+
if sid:
|
|
217
|
+
self.session_id = sid
|
|
218
|
+
elif etype == "token":
|
|
219
|
+
content = event.get("content", "")
|
|
220
|
+
if content:
|
|
221
|
+
await turn.append_token(content)
|
|
222
|
+
elif etype == "text_segment":
|
|
223
|
+
content = event.get("content", "")
|
|
224
|
+
if content:
|
|
225
|
+
await turn.append_token(content)
|
|
226
|
+
elif etype == "tool_start":
|
|
227
|
+
await turn.start_tool(
|
|
228
|
+
event.get("id", ""),
|
|
229
|
+
event.get("name", "tool"),
|
|
230
|
+
str(event.get("input", "")),
|
|
231
|
+
)
|
|
232
|
+
elif etype == "tool_end":
|
|
233
|
+
turn.finish_tool(event.get("id", ""), event.get("output_preview"), errored=False)
|
|
234
|
+
elif etype == "todos":
|
|
235
|
+
await turn.set_todos(event.get("items", []))
|
|
236
|
+
elif etype == "truncated":
|
|
237
|
+
await turn.append_token("\n\n*[response truncated]*")
|
|
238
|
+
elif etype == "error":
|
|
239
|
+
msg = event.get("error") or event.get("message") or "unknown error"
|
|
240
|
+
await turn.append_token(f"\n\n*[error: {msg}]*")
|
|
241
|
+
elif etype in ("done", "complete"):
|
|
242
|
+
return
|
|
243
|
+
# Other event types (subagent_*, skill_loaded, memory_updated, etc.)
|
|
244
|
+
# are silently ignored in v1.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Textual widgets for the chat TUI.
|
|
2
|
+
|
|
3
|
+
Each assistant turn is one AssistantTurn widget containing a sequence of
|
|
4
|
+
text segments, tool-call cards, and an optional todo list. Events from the
|
|
5
|
+
SSE stream mutate these widgets in place — no full re-renders.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from rich.markdown import Markdown
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from textual.containers import Vertical
|
|
15
|
+
from textual.widgets import Static
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UserBubble(Static):
|
|
19
|
+
"""One line at the top of a turn showing the user's message."""
|
|
20
|
+
|
|
21
|
+
DEFAULT_CSS = """
|
|
22
|
+
UserBubble {
|
|
23
|
+
margin: 1 0 0 0;
|
|
24
|
+
padding: 0 1;
|
|
25
|
+
color: $accent;
|
|
26
|
+
text-style: bold;
|
|
27
|
+
}
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, content: str) -> None:
|
|
31
|
+
super().__init__(Text("> ", style="bold cyan").append(content, style=""))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TextSegment(Static):
|
|
35
|
+
"""A streaming markdown segment of assistant text."""
|
|
36
|
+
|
|
37
|
+
DEFAULT_CSS = """
|
|
38
|
+
TextSegment { margin: 0 0 0 2; }
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
super().__init__("")
|
|
43
|
+
self._buf = ""
|
|
44
|
+
|
|
45
|
+
def append(self, chunk: str) -> None:
|
|
46
|
+
self._buf += chunk
|
|
47
|
+
self.update(Markdown(self._buf))
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def content(self) -> str:
|
|
51
|
+
return self._buf
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ToolCallCard(Static):
|
|
55
|
+
"""A single tool invocation: running → complete, with output preview."""
|
|
56
|
+
|
|
57
|
+
DEFAULT_CSS = """
|
|
58
|
+
ToolCallCard {
|
|
59
|
+
margin: 0 0 0 2;
|
|
60
|
+
padding: 0 1;
|
|
61
|
+
color: $text-muted;
|
|
62
|
+
}
|
|
63
|
+
ToolCallCard.-running { color: $warning; }
|
|
64
|
+
ToolCallCard.-done { color: $success; }
|
|
65
|
+
ToolCallCard.-error { color: $error; }
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, tool_id: str, name: str, input_str: str) -> None:
|
|
69
|
+
super().__init__()
|
|
70
|
+
self.tool_id = tool_id
|
|
71
|
+
self.name = name
|
|
72
|
+
self.input_str = input_str or ""
|
|
73
|
+
self.output_preview: Optional[str] = None
|
|
74
|
+
self.add_class("-running")
|
|
75
|
+
self._render()
|
|
76
|
+
|
|
77
|
+
def _render(self) -> None:
|
|
78
|
+
marker = "✓" if "-done" in self.classes else ("✗" if "-error" in self.classes else "▶")
|
|
79
|
+
head = Text(f"{marker} {self.name}", style="bold")
|
|
80
|
+
if self.input_str:
|
|
81
|
+
preview = self.input_str.strip().splitlines()[0][:120]
|
|
82
|
+
head.append(f" {preview}", style="dim")
|
|
83
|
+
body_lines = [head]
|
|
84
|
+
if self.output_preview:
|
|
85
|
+
for line in self.output_preview.strip().splitlines()[:8]:
|
|
86
|
+
body_lines.append(Text(f" {line[:160]}", style="dim"))
|
|
87
|
+
self.update(Text("\n").join(body_lines))
|
|
88
|
+
|
|
89
|
+
def finish(self, output_preview: Optional[str], errored: bool = False) -> None:
|
|
90
|
+
self.output_preview = output_preview
|
|
91
|
+
self.remove_class("-running")
|
|
92
|
+
self.add_class("-error" if errored else "-done")
|
|
93
|
+
self._render()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TodoList(Static):
|
|
97
|
+
"""Live checklist that updates whenever a `todos` event arrives."""
|
|
98
|
+
|
|
99
|
+
DEFAULT_CSS = """
|
|
100
|
+
TodoList {
|
|
101
|
+
margin: 0 0 0 2;
|
|
102
|
+
padding: 0 1;
|
|
103
|
+
color: $text-muted;
|
|
104
|
+
}
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def set_items(self, items: list[dict]) -> None:
|
|
108
|
+
if not items:
|
|
109
|
+
self.update("")
|
|
110
|
+
return
|
|
111
|
+
lines: list[Text] = []
|
|
112
|
+
for item in items:
|
|
113
|
+
status = item.get("status", "pending")
|
|
114
|
+
mark = {"completed": "[x]", "in_progress": "[~]", "pending": "[ ]"}.get(status, "[ ]")
|
|
115
|
+
style = {"completed": "green", "in_progress": "yellow"}.get(status, "")
|
|
116
|
+
lines.append(Text(f"{mark} {item.get('content', '')}", style=style))
|
|
117
|
+
self.update(Text("\n").join(lines))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class AssistantTurn(Vertical):
|
|
121
|
+
"""Container for one assistant response — text segments + tool cards + todos."""
|
|
122
|
+
|
|
123
|
+
DEFAULT_CSS = """
|
|
124
|
+
AssistantTurn {
|
|
125
|
+
height: auto;
|
|
126
|
+
margin: 0 0 1 0;
|
|
127
|
+
}
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(self) -> None:
|
|
131
|
+
super().__init__()
|
|
132
|
+
self._tools: dict[str, ToolCallCard] = {}
|
|
133
|
+
self._todo: Optional[TodoList] = None
|
|
134
|
+
self._last_text: Optional[TextSegment] = None
|
|
135
|
+
self._final = False
|
|
136
|
+
|
|
137
|
+
async def append_token(self, content: str) -> None:
|
|
138
|
+
if self._last_text is None:
|
|
139
|
+
self._last_text = TextSegment()
|
|
140
|
+
await self.mount(self._last_text)
|
|
141
|
+
self._last_text.append(content)
|
|
142
|
+
|
|
143
|
+
async def start_tool(self, tool_id: str, name: str, input_str: str) -> None:
|
|
144
|
+
card = ToolCallCard(tool_id, name, input_str)
|
|
145
|
+
self._tools[tool_id] = card
|
|
146
|
+
await self.mount(card)
|
|
147
|
+
# Break the text run — next tokens start a fresh paragraph below the card.
|
|
148
|
+
self._last_text = None
|
|
149
|
+
|
|
150
|
+
def finish_tool(self, tool_id: str, output_preview: Optional[str], errored: bool = False) -> None:
|
|
151
|
+
card = self._tools.get(tool_id)
|
|
152
|
+
if card is not None:
|
|
153
|
+
card.finish(output_preview, errored)
|
|
154
|
+
# Force a new text segment after a tool, even without a tool_start break.
|
|
155
|
+
self._last_text = None
|
|
156
|
+
|
|
157
|
+
async def set_todos(self, items: list[dict]) -> None:
|
|
158
|
+
if self._todo is None:
|
|
159
|
+
self._todo = TodoList()
|
|
160
|
+
await self.mount(self._todo)
|
|
161
|
+
self._todo.set_items(items)
|
|
162
|
+
|
|
163
|
+
def mark_final(self) -> None:
|
|
164
|
+
self._final = True
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
|
+
import shlex
|
|
6
7
|
import subprocess
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Optional
|
|
@@ -12,6 +13,25 @@ from virtuai_cli.security import check_command, jail_path, scrub_env
|
|
|
12
13
|
_EXECUTE_TIMEOUT = 300 # seconds
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
def _jail_wrap(command: str, workdir: Path) -> str:
|
|
17
|
+
"""Wrap command in a bash subshell that overrides cd to stay within workdir."""
|
|
18
|
+
jail = shlex.quote(str(workdir.resolve()))
|
|
19
|
+
script = "\n".join([
|
|
20
|
+
f"VIRTUAI_JAIL={jail}",
|
|
21
|
+
"cd() {",
|
|
22
|
+
" local dest",
|
|
23
|
+
' dest=$(builtin cd "${@:-$VIRTUAI_JAIL}" 2>/dev/null && pwd -P)',
|
|
24
|
+
' case "$dest" in',
|
|
25
|
+
' "$VIRTUAI_JAIL"|"$VIRTUAI_JAIL"/*) builtin cd "${@:-$VIRTUAI_JAIL}" ;;',
|
|
26
|
+
' *) echo "cd: permission denied (outside workdir)" >&2; return 1 ;;',
|
|
27
|
+
" esac",
|
|
28
|
+
"}",
|
|
29
|
+
"export -f cd",
|
|
30
|
+
command,
|
|
31
|
+
])
|
|
32
|
+
return f"bash -c {shlex.quote(script)}"
|
|
33
|
+
|
|
34
|
+
|
|
15
35
|
def execute(command: str, workdir: Path, timeout: int = _EXECUTE_TIMEOUT, extra_env: list[str] | None = None) -> dict:
|
|
16
36
|
"""Run a shell command. Returns {output, exit_code}."""
|
|
17
37
|
denial = check_command(command)
|
|
@@ -19,9 +39,10 @@ def execute(command: str, workdir: Path, timeout: int = _EXECUTE_TIMEOUT, extra_
|
|
|
19
39
|
return {"output": f"blocked by local denylist: {denial}", "exit_code": 126}
|
|
20
40
|
|
|
21
41
|
env = scrub_env(workdir, extra_env)
|
|
42
|
+
wrapped = _jail_wrap(command, workdir)
|
|
22
43
|
try:
|
|
23
44
|
result = subprocess.run(
|
|
24
|
-
|
|
45
|
+
wrapped,
|
|
25
46
|
shell=True,
|
|
26
47
|
cwd=str(workdir),
|
|
27
48
|
env=env,
|
|
@@ -145,6 +145,17 @@ def run(
|
|
|
145
145
|
# status — show pairing and connection state
|
|
146
146
|
# ---------------------------------------------------------------------------
|
|
147
147
|
|
|
148
|
+
@app.command()
|
|
149
|
+
def chat(
|
|
150
|
+
agent: Optional[str] = typer.Option(None, "--agent", help="Agent ID or name (skipped if only one agent exists)"),
|
|
151
|
+
workdir: Optional[Path] = typer.Option(None, "--workdir", help="Working directory the agent can act on"),
|
|
152
|
+
server: Optional[str] = typer.Option(None, "--server"),
|
|
153
|
+
):
|
|
154
|
+
"""Open the chat TUI and talk to an agent from this terminal."""
|
|
155
|
+
from virtuai_cli.chat import run_chat
|
|
156
|
+
run_chat(workdir=workdir, server=server, agent=agent)
|
|
157
|
+
|
|
158
|
+
|
|
148
159
|
@app.command()
|
|
149
160
|
def status(
|
|
150
161
|
server: Optional[str] = typer.Option(None, "--server"),
|
|
@@ -9,7 +9,7 @@ import platform
|
|
|
9
9
|
import sys
|
|
10
10
|
import time
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from typing import Optional
|
|
12
|
+
from typing import Awaitable, Callable, Optional
|
|
13
13
|
|
|
14
14
|
_AUDIT_DIR = Path.home() / ".virtuai"
|
|
15
15
|
|
|
@@ -20,6 +20,10 @@ from virtuai_cli import __version__
|
|
|
20
20
|
from virtuai_cli import executor as exec_
|
|
21
21
|
from virtuai_cli.security import resolve_workdir
|
|
22
22
|
|
|
23
|
+
# Callback the TUI uses to capture connection events (level, message) instead
|
|
24
|
+
# of printing to stdout (which would scramble the Textual screen).
|
|
25
|
+
StatusCallback = Callable[[str, str], None]
|
|
26
|
+
|
|
23
27
|
logger = logging.getLogger(__name__)
|
|
24
28
|
console = Console()
|
|
25
29
|
|
|
@@ -38,7 +42,12 @@ def _audit(workdir: Path, command: str, exit_code: int, elapsed: float) -> None:
|
|
|
38
42
|
_RECONNECT_DELAYS = [1, 2, 4, 8, 16, 30] # backoff steps
|
|
39
43
|
|
|
40
44
|
|
|
41
|
-
|
|
45
|
+
def _default_on_status(level: str, msg: str) -> None:
|
|
46
|
+
style = {"info": "green", "warn": "yellow", "error": "red"}.get(level, "")
|
|
47
|
+
console.print(f"[{style}]{msg}[/{style}]" if style else msg)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _handle_frame(frame: dict, workdir: Path, ws, on_status: StatusCallback) -> None:
|
|
42
51
|
"""Dispatch a server frame and send the result back."""
|
|
43
52
|
ftype = frame.get("type")
|
|
44
53
|
fid = frame.get("id")
|
|
@@ -55,7 +64,7 @@ async def _handle_frame(frame: dict, workdir: Path, ws) -> None:
|
|
|
55
64
|
"workdir": str(workdir),
|
|
56
65
|
}
|
|
57
66
|
await ws.send(json.dumps(reply))
|
|
58
|
-
|
|
67
|
+
on_status("info", f"Connected. Workdir: {workdir}")
|
|
59
68
|
return
|
|
60
69
|
|
|
61
70
|
if ftype == "ping":
|
|
@@ -65,7 +74,8 @@ async def _handle_frame(frame: dict, workdir: Path, ws) -> None:
|
|
|
65
74
|
if ftype == "execute":
|
|
66
75
|
command = frame.get("command", "")
|
|
67
76
|
timeout = int(frame.get("timeout_secs", 300))
|
|
68
|
-
|
|
77
|
+
preview = command[:80] + ("..." if len(command) > 80 else "")
|
|
78
|
+
on_status("exec", preview)
|
|
69
79
|
t0 = time.monotonic()
|
|
70
80
|
loop = asyncio.get_event_loop()
|
|
71
81
|
result = await loop.run_in_executor(
|
|
@@ -97,7 +107,7 @@ async def _handle_frame(frame: dict, workdir: Path, ws) -> None:
|
|
|
97
107
|
return
|
|
98
108
|
|
|
99
109
|
if ftype == "shutdown":
|
|
100
|
-
|
|
110
|
+
on_status("warn", f"Server requested shutdown: {frame.get('reason', '')}")
|
|
101
111
|
raise SystemExit(0)
|
|
102
112
|
|
|
103
113
|
|
|
@@ -114,8 +124,14 @@ def _ssl_context():
|
|
|
114
124
|
return ctx
|
|
115
125
|
|
|
116
126
|
|
|
117
|
-
async def run_session(
|
|
127
|
+
async def run_session(
|
|
128
|
+
ws_url: str,
|
|
129
|
+
token: str,
|
|
130
|
+
workdir: Path,
|
|
131
|
+
on_status: Optional[StatusCallback] = None,
|
|
132
|
+
) -> None:
|
|
118
133
|
"""Run a single connected session until the WebSocket closes."""
|
|
134
|
+
on_status = on_status or _default_on_status
|
|
119
135
|
url = f"{ws_url}?token={token}"
|
|
120
136
|
# Use certifi CA bundle for SSL so WSS works on macOS without the
|
|
121
137
|
# 'Install Certificates.command' workaround.
|
|
@@ -126,30 +142,40 @@ async def run_session(ws_url: str, token: str, workdir: Path) -> None:
|
|
|
126
142
|
async for raw in ws:
|
|
127
143
|
try:
|
|
128
144
|
frame = json.loads(raw)
|
|
129
|
-
await _handle_frame(frame, workdir, ws)
|
|
145
|
+
await _handle_frame(frame, workdir, ws, on_status)
|
|
130
146
|
except SystemExit:
|
|
131
147
|
raise
|
|
132
148
|
except Exception as exc:
|
|
133
149
|
logger.warning("Frame handling error: %s", exc)
|
|
134
150
|
|
|
135
151
|
|
|
136
|
-
async def run_forever(
|
|
137
|
-
|
|
152
|
+
async def run_forever(
|
|
153
|
+
server_url: str,
|
|
154
|
+
token: str,
|
|
155
|
+
workdir: Path,
|
|
156
|
+
on_status: Optional[StatusCallback] = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Connect with exponential backoff, reconnecting on drop.
|
|
159
|
+
|
|
160
|
+
Pass `on_status` to redirect connection messages to a TUI/log sink
|
|
161
|
+
instead of printing to stdout (which would scramble a Textual screen).
|
|
162
|
+
"""
|
|
163
|
+
on_status = on_status or _default_on_status
|
|
138
164
|
ws_url = server_url.rstrip("/").replace("https://", "wss://").replace("http://", "ws://")
|
|
139
165
|
ws_url = f"{ws_url}/api/cli/ws"
|
|
140
166
|
|
|
141
167
|
workdir.mkdir(parents=True, exist_ok=True)
|
|
142
|
-
|
|
168
|
+
on_status("info", f"Connecting to {server_url} ...")
|
|
143
169
|
|
|
144
170
|
attempt = 0
|
|
145
171
|
while True:
|
|
146
172
|
try:
|
|
147
|
-
await run_session(ws_url, token, workdir)
|
|
173
|
+
await run_session(ws_url, token, workdir, on_status)
|
|
148
174
|
attempt = 0 # successful session resets backoff
|
|
149
175
|
except SystemExit:
|
|
150
176
|
return
|
|
151
177
|
except Exception as exc:
|
|
152
178
|
delay = _RECONNECT_DELAYS[min(attempt, len(_RECONNECT_DELAYS) - 1)]
|
|
153
|
-
|
|
179
|
+
on_status("error", f"Disconnected: {exc}. Retrying in {delay}s ...")
|
|
154
180
|
await asyncio.sleep(delay)
|
|
155
181
|
attempt += 1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: virtuai-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Run VirtuAI deep agents on your local machine
|
|
5
5
|
Author-email: uCloudStore <lmoreno@ucloudstore.com>
|
|
6
6
|
License: Proprietary
|
|
@@ -14,10 +14,12 @@ Requires-Python: >=3.11
|
|
|
14
14
|
Description-Content-Type: text/markdown
|
|
15
15
|
Requires-Dist: websockets>=12.0
|
|
16
16
|
Requires-Dist: httpx[http2]>=0.27
|
|
17
|
+
Requires-Dist: httpx-sse>=0.4
|
|
17
18
|
Requires-Dist: certifi>=2024.0
|
|
18
19
|
Requires-Dist: keyring>=25.0
|
|
19
20
|
Requires-Dist: typer>=0.12
|
|
20
21
|
Requires-Dist: rich>=13.0
|
|
22
|
+
Requires-Dist: textual>=0.50
|
|
21
23
|
|
|
22
24
|
# VirtuAI CLI
|
|
23
25
|
|
|
@@ -11,4 +11,9 @@ src/virtuai_cli.egg-info/SOURCES.txt
|
|
|
11
11
|
src/virtuai_cli.egg-info/dependency_links.txt
|
|
12
12
|
src/virtuai_cli.egg-info/entry_points.txt
|
|
13
13
|
src/virtuai_cli.egg-info/requires.txt
|
|
14
|
-
src/virtuai_cli.egg-info/top_level.txt
|
|
14
|
+
src/virtuai_cli.egg-info/top_level.txt
|
|
15
|
+
src/virtuai_cli/chat/__init__.py
|
|
16
|
+
src/virtuai_cli/chat/command.py
|
|
17
|
+
src/virtuai_cli/chat/sse.py
|
|
18
|
+
src/virtuai_cli/chat/tui.py
|
|
19
|
+
src/virtuai_cli/chat/widgets.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|