virtuai-cli 0.8.2__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.2 → virtuai_cli-0.8.3}/PKG-INFO +1 -1
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/pyproject.toml +1 -1
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/__init__.py +1 -1
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/tui.py +57 -7
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/widgets.py +25 -0
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/runner.py +100 -11
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/README.md +0 -0
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/setup.cfg +0 -0
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/ask.py +0 -0
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/command.py +0 -0
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/history.py +0 -0
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/sse.py +0 -0
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/executor.py +0 -0
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/main.py +0 -0
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/requires.txt +0 -0
- {virtuai_cli-0.8.2 → 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()
|
|
@@ -48,7 +50,13 @@ def _default_on_status(level: str, msg: str) -> None:
|
|
|
48
50
|
console.print(f"[{style}]{msg}[/{style}]" if style else msg)
|
|
49
51
|
|
|
50
52
|
|
|
51
|
-
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:
|
|
52
60
|
"""Dispatch a server frame and send the result back."""
|
|
53
61
|
ftype = frame.get("type")
|
|
54
62
|
fid = frame.get("id")
|
|
@@ -77,19 +85,97 @@ async def _handle_frame(frame: dict, workdir: Path, ws, on_status: StatusCallbac
|
|
|
77
85
|
timeout = int(frame.get("timeout_secs", 300))
|
|
78
86
|
preview = command[:80] + ("..." if len(command) > 80 else "")
|
|
79
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)
|
|
80
102
|
t0 = time.monotonic()
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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)
|
|
85
158
|
elapsed = time.monotonic() - t0
|
|
86
|
-
|
|
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)
|
|
87
173
|
await ws.send(json.dumps({
|
|
88
174
|
"v": 1,
|
|
89
175
|
"id": fid,
|
|
90
176
|
"type": "execute_result",
|
|
91
|
-
"output":
|
|
92
|
-
"exit_code":
|
|
177
|
+
"output": full_output,
|
|
178
|
+
"exit_code": exit_code,
|
|
93
179
|
}))
|
|
94
180
|
return
|
|
95
181
|
|
|
@@ -130,6 +216,7 @@ async def run_session(
|
|
|
130
216
|
token: str,
|
|
131
217
|
workdir: Path,
|
|
132
218
|
on_status: Optional[StatusCallback] = None,
|
|
219
|
+
on_output: Optional[OutputCallback] = None,
|
|
133
220
|
) -> None:
|
|
134
221
|
"""Run a single connected session until the WebSocket closes."""
|
|
135
222
|
on_status = on_status or _default_on_status
|
|
@@ -150,7 +237,7 @@ async def run_session(
|
|
|
150
237
|
raise ConnectionError("No data received for 60 s — stale connection")
|
|
151
238
|
try:
|
|
152
239
|
frame = json.loads(raw)
|
|
153
|
-
await _handle_frame(frame, workdir, ws, on_status)
|
|
240
|
+
await _handle_frame(frame, workdir, ws, on_status, on_output)
|
|
154
241
|
except SystemExit:
|
|
155
242
|
raise
|
|
156
243
|
except Exception as exc:
|
|
@@ -162,11 +249,13 @@ async def run_forever(
|
|
|
162
249
|
token: str,
|
|
163
250
|
workdir: Path,
|
|
164
251
|
on_status: Optional[StatusCallback] = None,
|
|
252
|
+
on_output: Optional[OutputCallback] = None,
|
|
165
253
|
) -> None:
|
|
166
254
|
"""Connect with exponential backoff, reconnecting on drop.
|
|
167
255
|
|
|
168
256
|
Pass `on_status` to redirect connection messages to a TUI/log sink
|
|
169
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).
|
|
170
259
|
"""
|
|
171
260
|
on_status = on_status or _default_on_status
|
|
172
261
|
ws_url = server_url.rstrip("/").replace("https://", "wss://").replace("http://", "ws://")
|
|
@@ -178,7 +267,7 @@ async def run_forever(
|
|
|
178
267
|
attempt = 0
|
|
179
268
|
while True:
|
|
180
269
|
try:
|
|
181
|
-
await run_session(ws_url, token, workdir, on_status)
|
|
270
|
+
await run_session(ws_url, token, workdir, on_status, on_output)
|
|
182
271
|
attempt = 0 # successful session resets backoff
|
|
183
272
|
except SystemExit:
|
|
184
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
|