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.
Files changed (23) hide show
  1. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/PKG-INFO +1 -1
  2. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/pyproject.toml +1 -1
  3. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/__init__.py +1 -1
  4. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/chat/tui.py +119 -25
  5. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/chat/widgets.py +46 -3
  6. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
  7. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/README.md +0 -0
  8. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/setup.cfg +0 -0
  9. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/chat/__init__.py +0 -0
  10. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/chat/ask.py +0 -0
  11. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/chat/command.py +0 -0
  12. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/chat/history.py +0 -0
  13. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/chat/sse.py +0 -0
  14. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/config.py +0 -0
  15. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/executor.py +0 -0
  16. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/main.py +0 -0
  17. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/runner.py +0 -0
  18. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli/security.py +0 -0
  19. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
  20. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  21. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  22. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/src/virtuai_cli.egg-info/requires.txt +0 -0
  23. {virtuai_cli-0.7.3 → virtuai_cli-0.7.6}/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.7.3
3
+ Version: 0.7.6
4
4
  Summary: Run VirtuAI deep agents on your local machine
5
5
  Author-email: uCloudStore <lmoreno@ucloudstore.com>
6
6
  License: Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "virtuai-cli"
7
- version = "0.7.3"
7
+ version = "0.7.6"
8
8
  description = "Run VirtuAI deep agents on your local machine"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,2 +1,2 @@
1
1
  """VirtuAI local CLI."""
2
- __version__ = "0.7.3"
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
- return f"{self.workspace_name} · {self.agent_name}{model} · connecting…"
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
- # Runner runs on the same event loop, so call directly.
142
- self._set_status(msg)
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._set_status(f"runner error: {exc}")
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
- # The status widget may have been torn down during app shutdown —
153
- # swallow the lookup error so we don't surface a NoMatches on exit.
154
- model = f" · {self.current_model_id}" if self.current_model_id else ""
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
- await turn.start_tool(
589
- event.get("id", ""),
590
- event.get("name", "tool"),
591
- event.get("input", ""),
592
- display=event.get("display", ""),
593
- file_content=event.get("file_content"),
594
- file_content_before=event.get("file_content_before"),
595
- read_offset=event.get("read_offset"),
596
- read_limit=event.get("read_limit"),
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
- turn.finish_tool(event.get("id", ""), event.get("output_preview"), errored=False)
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
- await turn.append_token("\n\n*[response truncated]*")
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.update(Markdown(normalize_artifacts(self._buf)))
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
- self._tools: dict[str, ToolCallCard] = {}
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 0.7.3
3
+ Version: 0.7.6
4
4
  Summary: Run VirtuAI deep agents on your local machine
5
5
  Author-email: uCloudStore <lmoreno@ucloudstore.com>
6
6
  License: Proprietary
File without changes
File without changes