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.
Files changed (21) hide show
  1. {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/PKG-INFO +3 -1
  2. {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/pyproject.toml +3 -1
  3. {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli/__init__.py +1 -1
  4. virtuai_cli-0.2.0/src/virtuai_cli/chat/__init__.py +4 -0
  5. virtuai_cli-0.2.0/src/virtuai_cli/chat/command.py +97 -0
  6. virtuai_cli-0.2.0/src/virtuai_cli/chat/sse.py +51 -0
  7. virtuai_cli-0.2.0/src/virtuai_cli/chat/tui.py +244 -0
  8. virtuai_cli-0.2.0/src/virtuai_cli/chat/widgets.py +164 -0
  9. {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli/executor.py +22 -1
  10. {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli/main.py +11 -0
  11. {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli/runner.py +38 -12
  12. {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli.egg-info/PKG-INFO +3 -1
  13. {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli.egg-info/SOURCES.txt +6 -1
  14. {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli.egg-info/requires.txt +2 -0
  15. {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/README.md +0 -0
  16. {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/setup.cfg +0 -0
  17. {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli/config.py +0 -0
  18. {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli/security.py +0 -0
  19. {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  20. {virtuai_cli-0.1.0 → virtuai_cli-0.2.0}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  21. {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.1.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.1.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.1.0"
2
+ __version__ = "0.2.0"
@@ -0,0 +1,4 @@
1
+ """TUI chat — Claude-Code-style terminal interface for VirtuAI agents."""
2
+ from virtuai_cli.chat.command import run_chat
3
+
4
+ __all__ = ["run_chat"]
@@ -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
- command,
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
- async def _handle_frame(frame: dict, workdir: Path, ws) -> None:
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
- console.print(f"[green]Connected.[/green] Workdir: [bold]{workdir}[/bold]")
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
- console.print(f"[dim]exec:[/dim] {command[:80]}{'...' if len(command) > 80 else ''}")
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
- console.print(f"[yellow]Server requested shutdown:[/yellow] {frame.get('reason', '')}")
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(ws_url: str, token: str, workdir: Path) -> None:
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(server_url: str, token: str, workdir: Path) -> None:
137
- """Connect with exponential backoff, reconnecting on drop."""
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
- console.print(f"Connecting to [bold]{server_url}[/bold] ")
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
- console.print(f"[red]Disconnected:[/red] {exc}. Retrying in {delay}s ")
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.1.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
@@ -1,6 +1,8 @@
1
1
  websockets>=12.0
2
2
  httpx[http2]>=0.27
3
+ httpx-sse>=0.4
3
4
  certifi>=2024.0
4
5
  keyring>=25.0
5
6
  typer>=0.12
6
7
  rich>=13.0
8
+ textual>=0.50
File without changes
File without changes