virtuai-cli 0.8.1__tar.gz → 0.8.3__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.8.1 → virtuai_cli-0.8.3}/PKG-INFO +1 -1
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/pyproject.toml +1 -1
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/__init__.py +1 -1
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/tui.py +57 -7
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/widgets.py +25 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/runner.py +109 -12
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/README.md +0 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/setup.cfg +0 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/ask.py +0 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/command.py +0 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/history.py +0 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/sse.py +0 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/executor.py +0 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/main.py +0 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/requires.txt +0 -0
- {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/top_level.txt +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""VirtuAI local CLI."""
|
|
2
|
-
__version__ = "0.8.
|
|
2
|
+
__version__ = "0.8.3"
|
|
@@ -131,6 +131,9 @@ class ChatApp(App):
|
|
|
131
131
|
# Pending attachments to include with the next message — flipped to the
|
|
132
132
|
# multimodal endpoint when non-empty, cleared after a successful send.
|
|
133
133
|
self._pending_attachments: list[Path] = []
|
|
134
|
+
# Buffer for execute_output chunks that arrive before the matching
|
|
135
|
+
# tool_start SSE event has mounted the tool card.
|
|
136
|
+
self._streaming_output: dict[str, list[str]] = {}
|
|
134
137
|
|
|
135
138
|
# ── Layout ────────────────────────────────────────────────────────────
|
|
136
139
|
def compose(self) -> ComposeResult:
|
|
@@ -228,8 +231,22 @@ class ChatApp(App):
|
|
|
228
231
|
self._status_message = msg
|
|
229
232
|
self._refresh_status_bar()
|
|
230
233
|
|
|
234
|
+
def on_output(frame_id: str, chunk: str) -> None:
|
|
235
|
+
# Buffer the chunk — the SSE tool_start may not have mounted the
|
|
236
|
+
# card yet if the subprocess produces output almost immediately.
|
|
237
|
+
buf = self._streaming_output.setdefault(frame_id, [])
|
|
238
|
+
buf.append(chunk)
|
|
239
|
+
# Route directly to the tool card if it already exists.
|
|
240
|
+
if self._current_turn is not None:
|
|
241
|
+
card = self._current_turn._tools.get(frame_id)
|
|
242
|
+
if card is not None and hasattr(card, "append_output"):
|
|
243
|
+
card.append_output(chunk)
|
|
244
|
+
|
|
231
245
|
try:
|
|
232
|
-
await ws_runner.run_forever(
|
|
246
|
+
await ws_runner.run_forever(
|
|
247
|
+
self.server_url, self.token, self.workdir,
|
|
248
|
+
on_status=on_status, on_output=on_output,
|
|
249
|
+
)
|
|
233
250
|
except asyncio.CancelledError:
|
|
234
251
|
self._connection_state = "offline"
|
|
235
252
|
self._refresh_status_bar()
|
|
@@ -738,14 +755,38 @@ class ChatApp(App):
|
|
|
738
755
|
model_id=self.current_model_id,
|
|
739
756
|
)
|
|
740
757
|
|
|
758
|
+
# Pump the SSE stream from a background task into a queue.
|
|
759
|
+
# When the user cancels (Esc), we abandon the pump task without
|
|
760
|
+
# awaiting it — httpx teardown happens in the background instead of
|
|
761
|
+
# blocking the event loop while the user is trying to type.
|
|
762
|
+
_DONE: object = object()
|
|
763
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
764
|
+
|
|
765
|
+
async def _pump() -> None:
|
|
766
|
+
try:
|
|
767
|
+
async for event in stream_iter:
|
|
768
|
+
queue.put_nowait(event)
|
|
769
|
+
except asyncio.CancelledError:
|
|
770
|
+
pass
|
|
771
|
+
except Exception as exc:
|
|
772
|
+
queue.put_nowait({"type": "error", "error": str(exc)})
|
|
773
|
+
finally:
|
|
774
|
+
queue.put_nowait(_DONE)
|
|
775
|
+
|
|
776
|
+
pump_task = asyncio.create_task(_pump())
|
|
777
|
+
|
|
741
778
|
try:
|
|
742
|
-
|
|
743
|
-
await
|
|
779
|
+
while True:
|
|
780
|
+
item = await queue.get()
|
|
781
|
+
if item is _DONE:
|
|
782
|
+
break
|
|
783
|
+
await self._handle_event(item, turn)
|
|
744
784
|
self._follow_bottom()
|
|
745
785
|
except asyncio.CancelledError:
|
|
746
|
-
# User hit Esc
|
|
747
|
-
#
|
|
786
|
+
# User hit Esc. Cancel the pump but don't await it — let httpx
|
|
787
|
+
# close the connection in the background.
|
|
748
788
|
cancelled = True
|
|
789
|
+
pump_task.cancel()
|
|
749
790
|
raise
|
|
750
791
|
except Exception as exc:
|
|
751
792
|
try:
|
|
@@ -792,11 +833,12 @@ class ChatApp(App):
|
|
|
792
833
|
# story. Empty == "", "{}", or anything that strips to nothing.
|
|
793
834
|
input_val = event.get("input", "")
|
|
794
835
|
input_str = input_val if isinstance(input_val, str) else json.dumps(input_val, default=str)
|
|
836
|
+
tool_id = event.get("id", "")
|
|
795
837
|
if input_str.strip() in ("", "{}", "[]"):
|
|
796
|
-
turn._tools[
|
|
838
|
+
turn._tools[tool_id] = None # type: ignore[assignment]
|
|
797
839
|
else:
|
|
798
840
|
await turn.start_tool(
|
|
799
|
-
|
|
841
|
+
tool_id,
|
|
800
842
|
event.get("name", "tool"),
|
|
801
843
|
input_val,
|
|
802
844
|
display=event.get("display", ""),
|
|
@@ -805,6 +847,14 @@ class ChatApp(App):
|
|
|
805
847
|
read_offset=event.get("read_offset"),
|
|
806
848
|
read_limit=event.get("read_limit"),
|
|
807
849
|
)
|
|
850
|
+
# Flush any execute_output chunks that raced ahead of this
|
|
851
|
+
# tool_start event (can happen when a subprocess starts
|
|
852
|
+
# printing immediately, e.g. a fast SSH connection).
|
|
853
|
+
card = turn._tools.get(tool_id)
|
|
854
|
+
if card is not None:
|
|
855
|
+
for buffered_chunk in self._streaming_output.pop(tool_id, []):
|
|
856
|
+
if hasattr(card, "append_output"):
|
|
857
|
+
card.append_output(buffered_chunk)
|
|
808
858
|
elif etype == "tool_end":
|
|
809
859
|
# If we skipped the matching tool_start (empty input), don't try
|
|
810
860
|
# to finalize a card that doesn't exist.
|
|
@@ -221,6 +221,7 @@ class TextSegment(Static):
|
|
|
221
221
|
self._last_render_t: float = 0.0
|
|
222
222
|
self._render_pending: bool = False
|
|
223
223
|
self._render_timer: Any = None
|
|
224
|
+
self._cancelled: bool = False
|
|
224
225
|
|
|
225
226
|
def append(self, chunk: str) -> None:
|
|
226
227
|
self._buf += chunk
|
|
@@ -244,6 +245,7 @@ class TextSegment(Static):
|
|
|
244
245
|
def cancel_pending(self) -> None:
|
|
245
246
|
"""Drop any scheduled render — used on stream cancel so the cancel
|
|
246
247
|
doesn't trigger another expensive Markdown reparse a moment later."""
|
|
248
|
+
self._cancelled = True
|
|
247
249
|
if self._render_timer is not None:
|
|
248
250
|
try:
|
|
249
251
|
self._render_timer.stop()
|
|
@@ -253,6 +255,11 @@ class TextSegment(Static):
|
|
|
253
255
|
self._render_pending = False
|
|
254
256
|
|
|
255
257
|
def _flush(self) -> None:
|
|
258
|
+
# Guard: the timer may have already fired and queued this callback
|
|
259
|
+
# before cancel_pending() ran. Bail out so we don't do an expensive
|
|
260
|
+
# Markdown parse right as the user is trying to type.
|
|
261
|
+
if self._cancelled:
|
|
262
|
+
return
|
|
256
263
|
self._render_pending = False
|
|
257
264
|
self._render_timer = None
|
|
258
265
|
self._last_render_t = time.monotonic()
|
|
@@ -439,6 +446,8 @@ class ToolCallCard(Static):
|
|
|
439
446
|
self.tool_name = tool_name
|
|
440
447
|
self.input_str = input_str or ""
|
|
441
448
|
self.output_preview: Optional[str] = None
|
|
449
|
+
self._stream_buf: str = ""
|
|
450
|
+
self._last_stream_t: float = 0.0
|
|
442
451
|
self.add_class("-running")
|
|
443
452
|
self._redraw()
|
|
444
453
|
|
|
@@ -452,8 +461,24 @@ class ToolCallCard(Static):
|
|
|
452
461
|
if self.output_preview:
|
|
453
462
|
for line in self.output_preview.strip().splitlines()[:8]:
|
|
454
463
|
body_lines.append(Text(f" {line[:160]}", style="dim"))
|
|
464
|
+
elif self._stream_buf:
|
|
465
|
+
# Show the last 8 lines of live streaming output while the tool runs.
|
|
466
|
+
for line in self._stream_buf.splitlines()[-8:]:
|
|
467
|
+
body_lines.append(Text(f" {line[:160]}", style="dim cyan"))
|
|
455
468
|
self.update(Text("\n").join(body_lines))
|
|
456
469
|
|
|
470
|
+
def append_output(self, chunk: str) -> None:
|
|
471
|
+
"""Append a live streaming chunk from the WS execute_output frame."""
|
|
472
|
+
if "-done" in self.classes or "-error" in self.classes:
|
|
473
|
+
return
|
|
474
|
+
self._stream_buf += chunk
|
|
475
|
+
# Throttle redraws to ~10 Hz — fast enough to feel live, cheap enough
|
|
476
|
+
# not to stall the event loop on a command that floods stdout.
|
|
477
|
+
now = time.monotonic()
|
|
478
|
+
if now - self._last_stream_t >= 0.1:
|
|
479
|
+
self._last_stream_t = now
|
|
480
|
+
self._redraw()
|
|
481
|
+
|
|
457
482
|
def finish(self, output_preview: Optional[str], errored: bool = False) -> None:
|
|
458
483
|
self.output_preview = output_preview
|
|
459
484
|
self.remove_class("-running")
|
|
@@ -9,7 +9,7 @@ import platform
|
|
|
9
9
|
import sys
|
|
10
10
|
import time
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from typing import
|
|
12
|
+
from typing import Callable, Optional
|
|
13
13
|
|
|
14
14
|
_AUDIT_DIR = Path.home() / ".virtuai"
|
|
15
15
|
|
|
@@ -23,6 +23,8 @@ from virtuai_cli.security import resolve_workdir
|
|
|
23
23
|
# Callback the TUI uses to capture connection events (level, message) instead
|
|
24
24
|
# of printing to stdout (which would scramble the Textual screen).
|
|
25
25
|
StatusCallback = Callable[[str, str], None]
|
|
26
|
+
# Callback for streaming execute output: (frame_id, chunk)
|
|
27
|
+
OutputCallback = Callable[[str, str], None]
|
|
26
28
|
|
|
27
29
|
logger = logging.getLogger(__name__)
|
|
28
30
|
console = Console()
|
|
@@ -40,6 +42,7 @@ def _audit(workdir: Path, command: str, exit_code: int, elapsed: float) -> None:
|
|
|
40
42
|
pass
|
|
41
43
|
|
|
42
44
|
_RECONNECT_DELAYS = [1, 2, 4, 8, 16, 30] # backoff steps
|
|
45
|
+
_RECV_TIMEOUT = 60.0 # seconds of silence before declaring the connection stale
|
|
43
46
|
|
|
44
47
|
|
|
45
48
|
def _default_on_status(level: str, msg: str) -> None:
|
|
@@ -47,7 +50,13 @@ def _default_on_status(level: str, msg: str) -> None:
|
|
|
47
50
|
console.print(f"[{style}]{msg}[/{style}]" if style else msg)
|
|
48
51
|
|
|
49
52
|
|
|
50
|
-
async def _handle_frame(
|
|
53
|
+
async def _handle_frame(
|
|
54
|
+
frame: dict,
|
|
55
|
+
workdir: Path,
|
|
56
|
+
ws,
|
|
57
|
+
on_status: StatusCallback,
|
|
58
|
+
on_output: Optional[OutputCallback] = None,
|
|
59
|
+
) -> None:
|
|
51
60
|
"""Dispatch a server frame and send the result back."""
|
|
52
61
|
ftype = frame.get("type")
|
|
53
62
|
fid = frame.get("id")
|
|
@@ -76,19 +85,97 @@ async def _handle_frame(frame: dict, workdir: Path, ws, on_status: StatusCallbac
|
|
|
76
85
|
timeout = int(frame.get("timeout_secs", 300))
|
|
77
86
|
preview = command[:80] + ("..." if len(command) > 80 else "")
|
|
78
87
|
on_status("exec", preview)
|
|
88
|
+
|
|
89
|
+
# Security checks — same policy as the sync executor.
|
|
90
|
+
from virtuai_cli.security import check_command, scrub_env
|
|
91
|
+
denial = check_command(command)
|
|
92
|
+
if denial:
|
|
93
|
+
await ws.send(json.dumps({
|
|
94
|
+
"v": 1, "id": fid, "type": "execute_result",
|
|
95
|
+
"output": f"blocked by local denylist: {denial}",
|
|
96
|
+
"exit_code": 126,
|
|
97
|
+
}))
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
env = scrub_env(workdir)
|
|
101
|
+
wrapped = exec_._jail_wrap(command, workdir)
|
|
79
102
|
t0 = time.monotonic()
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
proc = await asyncio.create_subprocess_shell(
|
|
106
|
+
wrapped,
|
|
107
|
+
stdout=asyncio.subprocess.PIPE,
|
|
108
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
109
|
+
cwd=str(workdir),
|
|
110
|
+
env=env,
|
|
111
|
+
)
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
await ws.send(json.dumps({
|
|
114
|
+
"v": 1, "id": fid, "type": "execute_result",
|
|
115
|
+
"output": f"executor error: {exc}",
|
|
116
|
+
"exit_code": 1,
|
|
117
|
+
}))
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
chunks: list[str] = []
|
|
121
|
+
total_bytes = 0
|
|
122
|
+
timed_out = False
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
async with asyncio.timeout(timeout):
|
|
126
|
+
async for raw_line in proc.stdout:
|
|
127
|
+
chunk = raw_line.decode("utf-8", errors="replace")
|
|
128
|
+
chunks.append(chunk)
|
|
129
|
+
total_bytes += len(chunk)
|
|
130
|
+
if on_output and fid:
|
|
131
|
+
on_output(fid, chunk)
|
|
132
|
+
# Forward chunk to server — server may show it in the agent
|
|
133
|
+
# trace or buffer it; either way the agent gets the full
|
|
134
|
+
# execute_result below.
|
|
135
|
+
try:
|
|
136
|
+
await ws.send(json.dumps({
|
|
137
|
+
"v": 1, "id": fid, "type": "execute_output",
|
|
138
|
+
"output": chunk,
|
|
139
|
+
}))
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
if total_bytes > exec_._MAX_OUTPUT_BYTES:
|
|
143
|
+
proc.kill()
|
|
144
|
+
break
|
|
145
|
+
except TimeoutError:
|
|
146
|
+
timed_out = True
|
|
147
|
+
try:
|
|
148
|
+
proc.kill()
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
exit_code = proc.returncode if proc.returncode is not None else (124 if timed_out else 1)
|
|
84
158
|
elapsed = time.monotonic() - t0
|
|
85
|
-
|
|
159
|
+
|
|
160
|
+
full_output = "".join(chunks)
|
|
161
|
+
if timed_out:
|
|
162
|
+
full_output += f"\ncommand timed out after {timeout}s"
|
|
163
|
+
exit_code = 124
|
|
164
|
+
elif total_bytes > exec_._MAX_OUTPUT_BYTES:
|
|
165
|
+
keep = exec_._MAX_OUTPUT_BYTES // 2
|
|
166
|
+
full_output = (
|
|
167
|
+
full_output[:keep]
|
|
168
|
+
+ f"\n\n... [output truncated: {total_bytes:,} bytes total] ...\n\n"
|
|
169
|
+
+ full_output[-keep:]
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
_audit(workdir, command, exit_code, elapsed)
|
|
86
173
|
await ws.send(json.dumps({
|
|
87
174
|
"v": 1,
|
|
88
175
|
"id": fid,
|
|
89
176
|
"type": "execute_result",
|
|
90
|
-
"output":
|
|
91
|
-
"exit_code":
|
|
177
|
+
"output": full_output,
|
|
178
|
+
"exit_code": exit_code,
|
|
92
179
|
}))
|
|
93
180
|
return
|
|
94
181
|
|
|
@@ -129,6 +216,7 @@ async def run_session(
|
|
|
129
216
|
token: str,
|
|
130
217
|
workdir: Path,
|
|
131
218
|
on_status: Optional[StatusCallback] = None,
|
|
219
|
+
on_output: Optional[OutputCallback] = None,
|
|
132
220
|
) -> None:
|
|
133
221
|
"""Run a single connected session until the WebSocket closes."""
|
|
134
222
|
on_status = on_status or _default_on_status
|
|
@@ -139,10 +227,17 @@ async def run_session(
|
|
|
139
227
|
# JSON pings every 20 s to keep the connection alive (cli_api/router.py).
|
|
140
228
|
ssl_ctx = _ssl_context() if ws_url.startswith("wss://") else None
|
|
141
229
|
async with websockets.connect(url, ping_interval=None, ssl=ssl_ctx) as ws:
|
|
142
|
-
|
|
230
|
+
while True:
|
|
231
|
+
try:
|
|
232
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=_RECV_TIMEOUT)
|
|
233
|
+
except asyncio.TimeoutError:
|
|
234
|
+
# No frame received within the timeout window — the server
|
|
235
|
+
# sends a JSON ping every ~20 s, so 60 s of silence means the
|
|
236
|
+
# TCP connection is stale without a clean close.
|
|
237
|
+
raise ConnectionError("No data received for 60 s — stale connection")
|
|
143
238
|
try:
|
|
144
239
|
frame = json.loads(raw)
|
|
145
|
-
await _handle_frame(frame, workdir, ws, on_status)
|
|
240
|
+
await _handle_frame(frame, workdir, ws, on_status, on_output)
|
|
146
241
|
except SystemExit:
|
|
147
242
|
raise
|
|
148
243
|
except Exception as exc:
|
|
@@ -154,11 +249,13 @@ async def run_forever(
|
|
|
154
249
|
token: str,
|
|
155
250
|
workdir: Path,
|
|
156
251
|
on_status: Optional[StatusCallback] = None,
|
|
252
|
+
on_output: Optional[OutputCallback] = None,
|
|
157
253
|
) -> None:
|
|
158
254
|
"""Connect with exponential backoff, reconnecting on drop.
|
|
159
255
|
|
|
160
256
|
Pass `on_status` to redirect connection messages to a TUI/log sink
|
|
161
257
|
instead of printing to stdout (which would scramble a Textual screen).
|
|
258
|
+
Pass `on_output` to receive streaming execute_output chunks (frame_id, chunk).
|
|
162
259
|
"""
|
|
163
260
|
on_status = on_status or _default_on_status
|
|
164
261
|
ws_url = server_url.rstrip("/").replace("https://", "wss://").replace("http://", "ws://")
|
|
@@ -170,7 +267,7 @@ async def run_forever(
|
|
|
170
267
|
attempt = 0
|
|
171
268
|
while True:
|
|
172
269
|
try:
|
|
173
|
-
await run_session(ws_url, token, workdir, on_status)
|
|
270
|
+
await run_session(ws_url, token, workdir, on_status, on_output)
|
|
174
271
|
attempt = 0 # successful session resets backoff
|
|
175
272
|
except SystemExit:
|
|
176
273
|
return
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|