virtuai-cli 0.7.3__tar.gz → 0.7.6__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.7.3 → virtuai_cli-0.7.6}/PKG-INFO +1 -1
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/pyproject.toml +1 -1
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/__init__.py +1 -1
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/chat/tui.py +119 -25
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/chat/widgets.py +46 -3
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/README.md +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/setup.cfg +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/chat/ask.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/chat/command.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/chat/history.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/chat/sse.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/executor.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/main.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/runner.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli.egg-info/requires.txt +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli.egg-info/top_level.txt +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""VirtuAI local CLI."""
|
|
2
|
-
__version__ = "0.7.
|
|
2
|
+
__version__ = "0.7.6"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import json
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Optional
|
|
8
9
|
|
|
@@ -109,6 +110,12 @@ class ChatApp(App):
|
|
|
109
110
|
self._runner_task: Optional[asyncio.Task] = None
|
|
110
111
|
self._current_turn: Optional[AssistantTurn] = None
|
|
111
112
|
self._select_mode: bool = False
|
|
113
|
+
# Persistent state shown in the status bar — refreshed by a periodic
|
|
114
|
+
# tick so the user always sees current connection + working status.
|
|
115
|
+
self._connection_state: str = "connecting" # connecting | live | reconnecting | offline
|
|
116
|
+
self._streaming: bool = False
|
|
117
|
+
self._status_message: str = ""
|
|
118
|
+
self._spinner_idx: int = 0
|
|
112
119
|
|
|
113
120
|
# ── Layout ────────────────────────────────────────────────────────────
|
|
114
121
|
def compose(self) -> ComposeResult:
|
|
@@ -125,39 +132,102 @@ class ChatApp(App):
|
|
|
125
132
|
yield ChatInput(id="input")
|
|
126
133
|
yield Footer()
|
|
127
134
|
|
|
135
|
+
_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
136
|
+
|
|
128
137
|
def _initial_status(self) -> str:
|
|
138
|
+
# Composed once for the first paint; the tick timer takes over after mount.
|
|
139
|
+
return self._compose_status()
|
|
140
|
+
|
|
141
|
+
def _compose_status(self) -> str:
|
|
129
142
|
model = f" · {self.current_model_id}" if self.current_model_id else ""
|
|
130
|
-
|
|
143
|
+
|
|
144
|
+
conn = self._connection_state
|
|
145
|
+
if conn == "live":
|
|
146
|
+
conn_part = "[green]● live[/green]"
|
|
147
|
+
elif conn == "offline":
|
|
148
|
+
conn_part = "[red]○ offline[/red]"
|
|
149
|
+
elif conn == "reconnecting":
|
|
150
|
+
spin = self._SPINNER[self._spinner_idx % len(self._SPINNER)]
|
|
151
|
+
conn_part = f"[yellow]{spin} reconnecting[/yellow]"
|
|
152
|
+
else: # connecting
|
|
153
|
+
spin = self._SPINNER[self._spinner_idx % len(self._SPINNER)]
|
|
154
|
+
conn_part = f"[yellow]{spin} connecting[/yellow]"
|
|
155
|
+
|
|
156
|
+
if self._streaming:
|
|
157
|
+
spin = self._SPINNER[self._spinner_idx % len(self._SPINNER)]
|
|
158
|
+
work_part = f" · [cyan]{spin} working[/cyan]"
|
|
159
|
+
else:
|
|
160
|
+
work_part = ""
|
|
161
|
+
|
|
162
|
+
msg_part = f" · {self._status_message}" if self._status_message else ""
|
|
163
|
+
return (
|
|
164
|
+
f"{self.workspace_name} · {self.agent_name}{model} · "
|
|
165
|
+
f"{conn_part}{work_part}{msg_part}"
|
|
166
|
+
)
|
|
131
167
|
|
|
132
168
|
# ── Mount: start the WS runner in the background ──────────────────────
|
|
133
169
|
async def on_mount(self) -> None:
|
|
134
170
|
self.title = "VirtuAI"
|
|
135
171
|
self.sub_title = f"{self.agent_name} · {self.workspace_name}"
|
|
136
172
|
self.query_one(ChatInput).focus()
|
|
173
|
+
# 100 ms tick to animate the spinner whenever connection or stream
|
|
174
|
+
# state needs a moving indicator.
|
|
175
|
+
self.set_interval(0.1, self._tick_status)
|
|
137
176
|
self._runner_task = asyncio.create_task(self._run_ws())
|
|
138
177
|
|
|
178
|
+
def _tick_status(self) -> None:
|
|
179
|
+
# Only repaint when something is actually animating — otherwise we'd
|
|
180
|
+
# force a Textual reflow 10x/sec across the whole scrollback, which
|
|
181
|
+
# makes long conversations laggy.
|
|
182
|
+
moving = self._streaming or self._connection_state in ("connecting", "reconnecting")
|
|
183
|
+
if moving:
|
|
184
|
+
self._spinner_idx = (self._spinner_idx + 1) % len(self._SPINNER)
|
|
185
|
+
self._refresh_status_bar()
|
|
186
|
+
|
|
187
|
+
def _refresh_status_bar(self) -> None:
|
|
188
|
+
try:
|
|
189
|
+
self.query_one("#status", Static).update(self._compose_status())
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
|
|
139
193
|
async def _run_ws(self) -> None:
|
|
140
194
|
def on_status(level: str, msg: str) -> None:
|
|
141
|
-
|
|
142
|
-
|
|
195
|
+
low = msg.lower()
|
|
196
|
+
if low.startswith("connected"):
|
|
197
|
+
self._connection_state = "live"
|
|
198
|
+
self._status_message = ""
|
|
199
|
+
elif low.startswith("connecting"):
|
|
200
|
+
self._connection_state = "connecting"
|
|
201
|
+
self._status_message = ""
|
|
202
|
+
elif low.startswith("disconnected") or "retrying" in low:
|
|
203
|
+
self._connection_state = "reconnecting"
|
|
204
|
+
# Keep the runner's "Retrying in Ns…" message as context.
|
|
205
|
+
self._status_message = msg
|
|
206
|
+
elif level == "error":
|
|
207
|
+
self._connection_state = "offline"
|
|
208
|
+
self._status_message = msg
|
|
209
|
+
elif level == "exec":
|
|
210
|
+
# ephemeral one-line preview of what the agent is running
|
|
211
|
+
self._status_message = f"exec: {msg}"
|
|
212
|
+
else:
|
|
213
|
+
self._status_message = msg
|
|
214
|
+
self._refresh_status_bar()
|
|
143
215
|
|
|
144
216
|
try:
|
|
145
217
|
await ws_runner.run_forever(self.server_url, self.token, self.workdir, on_status=on_status)
|
|
146
218
|
except asyncio.CancelledError:
|
|
219
|
+
self._connection_state = "offline"
|
|
220
|
+
self._refresh_status_bar()
|
|
147
221
|
raise
|
|
148
222
|
except Exception as exc:
|
|
149
|
-
self.
|
|
223
|
+
self._connection_state = "offline"
|
|
224
|
+
self._status_message = f"runner error: {exc}"
|
|
225
|
+
self._refresh_status_bar()
|
|
150
226
|
|
|
151
227
|
def _set_status(self, text: str) -> None:
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
try:
|
|
156
|
-
self.query_one("#status", Static).update(
|
|
157
|
-
f"{self.workspace_name} · {self.agent_name}{model} · {text}"
|
|
158
|
-
)
|
|
159
|
-
except Exception:
|
|
160
|
-
pass
|
|
228
|
+
"""Set an ephemeral status message — connection/streaming glyphs stay."""
|
|
229
|
+
self._status_message = text
|
|
230
|
+
self._refresh_status_bar()
|
|
161
231
|
|
|
162
232
|
# ── Slash hints / autocomplete ────────────────────────────────────────
|
|
163
233
|
def _matching_commands(self, prefix: str) -> list[tuple[str, str]]:
|
|
@@ -535,6 +605,8 @@ class ChatApp(App):
|
|
|
535
605
|
|
|
536
606
|
# ── SSE stream → widget mutations ─────────────────────────────────────
|
|
537
607
|
async def _stream_response(self, message: str, turn: AssistantTurn) -> None:
|
|
608
|
+
self._streaming = True
|
|
609
|
+
self._refresh_status_bar()
|
|
538
610
|
try:
|
|
539
611
|
async for event in stream_chat(
|
|
540
612
|
self.server_url,
|
|
@@ -556,6 +628,8 @@ class ChatApp(App):
|
|
|
556
628
|
except Exception:
|
|
557
629
|
pass
|
|
558
630
|
finally:
|
|
631
|
+
self._streaming = False
|
|
632
|
+
self._refresh_status_bar()
|
|
559
633
|
try:
|
|
560
634
|
await turn.mark_final()
|
|
561
635
|
except Exception:
|
|
@@ -585,22 +659,42 @@ class ChatApp(App):
|
|
|
585
659
|
if content:
|
|
586
660
|
await turn.append_token(content)
|
|
587
661
|
elif etype == "tool_start":
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
662
|
+
# Skip tool-call cards whose input is empty — those are the visual
|
|
663
|
+
# debris of a tool call that was truncated mid-JSON when the model
|
|
664
|
+
# hit max_tokens. The card has no useful info and clutters the
|
|
665
|
+
# screen; the accompanying `truncated` event already tells the
|
|
666
|
+
# story. Empty == "", "{}", or anything that strips to nothing.
|
|
667
|
+
input_val = event.get("input", "")
|
|
668
|
+
input_str = input_val if isinstance(input_val, str) else json.dumps(input_val, default=str)
|
|
669
|
+
if input_str.strip() in ("", "{}", "[]"):
|
|
670
|
+
turn._tools[event.get("id", "")] = None # type: ignore[assignment]
|
|
671
|
+
else:
|
|
672
|
+
await turn.start_tool(
|
|
673
|
+
event.get("id", ""),
|
|
674
|
+
event.get("name", "tool"),
|
|
675
|
+
input_val,
|
|
676
|
+
display=event.get("display", ""),
|
|
677
|
+
file_content=event.get("file_content"),
|
|
678
|
+
file_content_before=event.get("file_content_before"),
|
|
679
|
+
read_offset=event.get("read_offset"),
|
|
680
|
+
read_limit=event.get("read_limit"),
|
|
681
|
+
)
|
|
598
682
|
elif etype == "tool_end":
|
|
599
|
-
|
|
683
|
+
# If we skipped the matching tool_start (empty input), don't try
|
|
684
|
+
# to finalize a card that doesn't exist.
|
|
685
|
+
if turn._tools.get(event.get("id", "")) is not None:
|
|
686
|
+
turn.finish_tool(event.get("id", ""), event.get("output_preview"), errored=False)
|
|
600
687
|
elif etype == "todos":
|
|
601
688
|
await turn.set_todos(event.get("items", []))
|
|
602
689
|
elif etype == "truncated":
|
|
603
|
-
|
|
690
|
+
# Dedupe: when max_tokens hits repeatedly on multiple model passes
|
|
691
|
+
# in a single turn (e.g. while a big tool-call arg gets retried),
|
|
692
|
+
# we don't want a wall of `[response truncated]` markers.
|
|
693
|
+
if not turn._truncated_shown:
|
|
694
|
+
await turn.append_token(
|
|
695
|
+
"\n\n*[response truncated — try raising the agent's max_tokens]*"
|
|
696
|
+
)
|
|
697
|
+
turn._truncated_shown = True
|
|
604
698
|
elif etype == "error":
|
|
605
699
|
msg = event.get("error") or event.get("message") or "unknown error"
|
|
606
700
|
await turn.append_token(f"\n\n*[error: {msg}]*")
|
|
@@ -200,19 +200,52 @@ class UserBubble(Static):
|
|
|
200
200
|
|
|
201
201
|
|
|
202
202
|
class TextSegment(Static):
|
|
203
|
-
"""A streaming markdown segment of assistant text.
|
|
203
|
+
"""A streaming markdown segment of assistant text.
|
|
204
|
+
|
|
205
|
+
Renders are throttled to ~20 Hz: the markdown AST is rebuilt from the
|
|
206
|
+
full buffer on every paint (Rich/mistletoe parse from scratch), so
|
|
207
|
+
rendering once per token on a long response is O(n²) and dominates CPU.
|
|
208
|
+
With throttling, bursts of tokens coalesce into a single render and any
|
|
209
|
+
final pending render is flushed via mark_final()/flush().
|
|
210
|
+
"""
|
|
204
211
|
|
|
205
212
|
DEFAULT_CSS = """
|
|
206
213
|
TextSegment { margin: 0 0 0 2; }
|
|
207
214
|
"""
|
|
208
215
|
|
|
216
|
+
_RENDER_INTERVAL = 0.05 # seconds (= 20 fps)
|
|
217
|
+
|
|
209
218
|
def __init__(self) -> None:
|
|
210
219
|
super().__init__("")
|
|
211
220
|
self._buf = ""
|
|
221
|
+
self._last_render_t: float = 0.0
|
|
222
|
+
self._render_pending: bool = False
|
|
212
223
|
|
|
213
224
|
def append(self, chunk: str) -> None:
|
|
214
225
|
self._buf += chunk
|
|
215
|
-
self.
|
|
226
|
+
if self._render_pending:
|
|
227
|
+
# A flush is already scheduled and will pick up the new content.
|
|
228
|
+
return
|
|
229
|
+
now = time.monotonic()
|
|
230
|
+
elapsed = now - self._last_render_t
|
|
231
|
+
if elapsed >= self._RENDER_INTERVAL:
|
|
232
|
+
self._flush()
|
|
233
|
+
else:
|
|
234
|
+
self._render_pending = True
|
|
235
|
+
self.set_timer(self._RENDER_INTERVAL - elapsed, self._flush)
|
|
236
|
+
|
|
237
|
+
def flush(self) -> None:
|
|
238
|
+
"""Force a render now — used to finalize the segment at end-of-stream."""
|
|
239
|
+
self._flush()
|
|
240
|
+
|
|
241
|
+
def _flush(self) -> None:
|
|
242
|
+
self._render_pending = False
|
|
243
|
+
self._last_render_t = time.monotonic()
|
|
244
|
+
try:
|
|
245
|
+
self.update(Markdown(normalize_artifacts(self._buf)))
|
|
246
|
+
except Exception:
|
|
247
|
+
# Widget might be unmounted by the time a scheduled flush fires.
|
|
248
|
+
pass
|
|
216
249
|
|
|
217
250
|
@property
|
|
218
251
|
def content(self) -> str:
|
|
@@ -504,11 +537,14 @@ class AssistantTurn(Vertical):
|
|
|
504
537
|
|
|
505
538
|
def __init__(self, *, show_thinking: bool = True) -> None:
|
|
506
539
|
super().__init__()
|
|
507
|
-
|
|
540
|
+
# Value is either the card widget OR None when the matching tool_start
|
|
541
|
+
# was suppressed (empty/truncated args) — see tui.py _handle_event.
|
|
542
|
+
self._tools: dict[str, Optional[Any]] = {}
|
|
508
543
|
self._todo: Optional[TodoList] = None
|
|
509
544
|
self._last_text: Optional[TextSegment] = None
|
|
510
545
|
self._thinking: Optional[ThinkingIndicator] = None
|
|
511
546
|
self._show_thinking = show_thinking
|
|
547
|
+
self._truncated_shown: bool = False
|
|
512
548
|
self._final = False
|
|
513
549
|
|
|
514
550
|
async def on_mount(self) -> None:
|
|
@@ -582,4 +618,11 @@ class AssistantTurn(Vertical):
|
|
|
582
618
|
|
|
583
619
|
async def mark_final(self) -> None:
|
|
584
620
|
await self._stop_thinking()
|
|
621
|
+
# Force any throttled TextSegment to do its final render so the user
|
|
622
|
+
# doesn't see the last sub-50ms of tokens missing after stream-end.
|
|
623
|
+
for seg in self.query(TextSegment):
|
|
624
|
+
try:
|
|
625
|
+
seg.flush()
|
|
626
|
+
except Exception:
|
|
627
|
+
pass
|
|
585
628
|
self._final = True
|
|
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
|
|
File without changes
|