virtuai-cli 0.7.2__tar.gz → 0.7.5__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.2 → virtuai_cli-0.7.5}/PKG-INFO +1 -1
  2. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/pyproject.toml +1 -1
  3. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/__init__.py +1 -1
  4. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/tui.py +116 -25
  5. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/widgets.py +88 -2
  6. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
  7. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/README.md +0 -0
  8. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/setup.cfg +0 -0
  9. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/__init__.py +0 -0
  10. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/ask.py +0 -0
  11. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/command.py +0 -0
  12. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/history.py +0 -0
  13. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/sse.py +0 -0
  14. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/config.py +0 -0
  15. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/executor.py +0 -0
  16. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/main.py +0 -0
  17. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/runner.py +0 -0
  18. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/security.py +0 -0
  19. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
  20. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  21. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  22. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli.egg-info/requires.txt +0 -0
  23. {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/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.2
3
+ Version: 0.7.5
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.2"
7
+ version = "0.7.5"
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.2"
2
+ __version__ = "0.7.5"
@@ -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,99 @@ 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
+ moving = self._streaming or self._connection_state in ("connecting", "reconnecting")
180
+ if moving:
181
+ self._spinner_idx = (self._spinner_idx + 1) % len(self._SPINNER)
182
+ self._refresh_status_bar()
183
+
184
+ def _refresh_status_bar(self) -> None:
185
+ try:
186
+ self.query_one("#status", Static).update(self._compose_status())
187
+ except Exception:
188
+ pass
189
+
139
190
  async def _run_ws(self) -> None:
140
191
  def on_status(level: str, msg: str) -> None:
141
- # Runner runs on the same event loop, so call directly.
142
- self._set_status(msg)
192
+ low = msg.lower()
193
+ if low.startswith("connected"):
194
+ self._connection_state = "live"
195
+ self._status_message = ""
196
+ elif low.startswith("connecting"):
197
+ self._connection_state = "connecting"
198
+ self._status_message = ""
199
+ elif low.startswith("disconnected") or "retrying" in low:
200
+ self._connection_state = "reconnecting"
201
+ # Keep the runner's "Retrying in Ns…" message as context.
202
+ self._status_message = msg
203
+ elif level == "error":
204
+ self._connection_state = "offline"
205
+ self._status_message = msg
206
+ elif level == "exec":
207
+ # ephemeral one-line preview of what the agent is running
208
+ self._status_message = f"exec: {msg}"
209
+ else:
210
+ self._status_message = msg
211
+ self._refresh_status_bar()
143
212
 
144
213
  try:
145
214
  await ws_runner.run_forever(self.server_url, self.token, self.workdir, on_status=on_status)
146
215
  except asyncio.CancelledError:
216
+ self._connection_state = "offline"
217
+ self._refresh_status_bar()
147
218
  raise
148
219
  except Exception as exc:
149
- self._set_status(f"runner error: {exc}")
220
+ self._connection_state = "offline"
221
+ self._status_message = f"runner error: {exc}"
222
+ self._refresh_status_bar()
150
223
 
151
224
  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
225
+ """Set an ephemeral status message connection/streaming glyphs stay."""
226
+ self._status_message = text
227
+ self._refresh_status_bar()
161
228
 
162
229
  # ── Slash hints / autocomplete ────────────────────────────────────────
163
230
  def _matching_commands(self, prefix: str) -> list[tuple[str, str]]:
@@ -535,6 +602,8 @@ class ChatApp(App):
535
602
 
536
603
  # ── SSE stream → widget mutations ─────────────────────────────────────
537
604
  async def _stream_response(self, message: str, turn: AssistantTurn) -> None:
605
+ self._streaming = True
606
+ self._refresh_status_bar()
538
607
  try:
539
608
  async for event in stream_chat(
540
609
  self.server_url,
@@ -556,6 +625,8 @@ class ChatApp(App):
556
625
  except Exception:
557
626
  pass
558
627
  finally:
628
+ self._streaming = False
629
+ self._refresh_status_bar()
559
630
  try:
560
631
  await turn.mark_final()
561
632
  except Exception:
@@ -585,22 +656,42 @@ class ChatApp(App):
585
656
  if content:
586
657
  await turn.append_token(content)
587
658
  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
- )
659
+ # Skip tool-call cards whose input is empty — those are the visual
660
+ # debris of a tool call that was truncated mid-JSON when the model
661
+ # hit max_tokens. The card has no useful info and clutters the
662
+ # screen; the accompanying `truncated` event already tells the
663
+ # story. Empty == "", "{}", or anything that strips to nothing.
664
+ input_val = event.get("input", "")
665
+ input_str = input_val if isinstance(input_val, str) else json.dumps(input_val, default=str)
666
+ if input_str.strip() in ("", "{}", "[]"):
667
+ turn._tools[event.get("id", "")] = None # type: ignore[assignment]
668
+ else:
669
+ await turn.start_tool(
670
+ event.get("id", ""),
671
+ event.get("name", "tool"),
672
+ input_val,
673
+ display=event.get("display", ""),
674
+ file_content=event.get("file_content"),
675
+ file_content_before=event.get("file_content_before"),
676
+ read_offset=event.get("read_offset"),
677
+ read_limit=event.get("read_limit"),
678
+ )
598
679
  elif etype == "tool_end":
599
- turn.finish_tool(event.get("id", ""), event.get("output_preview"), errored=False)
680
+ # If we skipped the matching tool_start (empty input), don't try
681
+ # to finalize a card that doesn't exist.
682
+ if turn._tools.get(event.get("id", "")) is not None:
683
+ turn.finish_tool(event.get("id", ""), event.get("output_preview"), errored=False)
600
684
  elif etype == "todos":
601
685
  await turn.set_todos(event.get("items", []))
602
686
  elif etype == "truncated":
603
- await turn.append_token("\n\n*[response truncated]*")
687
+ # Dedupe: when max_tokens hits repeatedly on multiple model passes
688
+ # in a single turn (e.g. while a big tool-call arg gets retried),
689
+ # we don't want a wall of `[response truncated]` markers.
690
+ if not turn._truncated_shown:
691
+ await turn.append_token(
692
+ "\n\n*[response truncated — try raising the agent's max_tokens]*"
693
+ )
694
+ turn._truncated_shown = True
604
695
  elif etype == "error":
605
696
  msg = event.get("error") or event.get("message") or "unknown error"
606
697
  await turn.append_token(f"\n\n*[error: {msg}]*")
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  import difflib
11
11
  import json
12
12
  import random
13
+ import re
13
14
  import time
14
15
  from typing import Any, Optional
15
16
 
@@ -83,6 +84,88 @@ class ChatInput(TextArea):
83
84
  # rather than a generic ToolCallCard.
84
85
  _FILE_DISPLAYS = {"file", "file_written"}
85
86
 
87
+
88
+ # ─── Artifact-tag normalization ──────────────────────────────────────────────
89
+ # The agent emits rich content (mermaid diagrams, code, HTML, SVG) wrapped in
90
+ # <artifact type="..." title="..." language="...">…</artifact> XML tags so the
91
+ # web UI can render them as artifact cards. Terminals can't render mermaid or
92
+ # HTML visually, so we rewrite those tags into fenced markdown code blocks
93
+ # (with a header line) — that way Rich Markdown renders the source as a
94
+ # syntax-highlighted code block instead of either silently swallowing the
95
+ # whole block (HTML-tag handling) or interpreting `[Label]` in mermaid bodies
96
+ # as empty markdown links (which is what reduces "F1[FASE 1] --> F2[…]" to a
97
+ # bare "F1 --> F2 -->").
98
+
99
+ _ARTIFACT_OPEN_RE = re.compile(r"<artifact([^>]*)>", re.IGNORECASE)
100
+ _ARTIFACT_CLOSE_RE = re.compile(r"</artifact\s*>", re.IGNORECASE)
101
+ _ATTR_RE = re.compile(r"(\w+)=(?:\"([^\"]*?)\"|(\S+))")
102
+
103
+
104
+ def _parse_artifact_attrs(raw: str) -> dict[str, str]:
105
+ return {m.group(1): (m.group(2) if m.group(2) is not None else m.group(3) or "")
106
+ for m in _ATTR_RE.finditer(raw)}
107
+
108
+
109
+ def _artifact_header(attrs: dict[str, str], suffix: str = "") -> str:
110
+ atype = (attrs.get("type") or "code").strip()
111
+ title = (attrs.get("title") or "Artifact").strip()
112
+ icon = {
113
+ "mermaid": "📈",
114
+ "html": "🌐",
115
+ "svg": "🎨",
116
+ "code": "📄",
117
+ "markdown": "📝",
118
+ "pdf": "📕",
119
+ }.get(atype.lower(), "📎")
120
+ return f"\n\n**{icon} {atype} · {title}**{suffix}\n"
121
+
122
+
123
+ def _artifact_lang(attrs: dict[str, str]) -> str:
124
+ lang = (attrs.get("language") or attrs.get("lang") or "").strip()
125
+ if lang:
126
+ return lang
127
+ atype = (attrs.get("type") or "").strip().lower()
128
+ return {"mermaid": "mermaid", "html": "html", "svg": "xml"}.get(atype, "text")
129
+
130
+
131
+ def normalize_artifacts(text: str) -> str:
132
+ """Rewrite <artifact>…</artifact> blocks into fenced code blocks.
133
+
134
+ Handles both completed pairs (full body rendered inside a fence) and a
135
+ trailing unclosed `<artifact …>` left over while the model is still
136
+ streaming the body (renders with a "(generating…)" marker so the user
137
+ sees the artifact appear progressively).
138
+ """
139
+ if "<artifact" not in text.lower():
140
+ return text
141
+
142
+ out: list[str] = []
143
+ pos = 0
144
+ while True:
145
+ open_m = _ARTIFACT_OPEN_RE.search(text, pos)
146
+ if not open_m:
147
+ out.append(text[pos:])
148
+ break
149
+ # Emit prose before the opening tag.
150
+ out.append(text[pos:open_m.start()])
151
+ attrs = _parse_artifact_attrs(open_m.group(1))
152
+ lang = _artifact_lang(attrs)
153
+ body_start = open_m.end()
154
+ close_m = _ARTIFACT_CLOSE_RE.search(text, body_start)
155
+ if close_m:
156
+ body = text[body_start:close_m.start()].strip("\n")
157
+ out.append(_artifact_header(attrs))
158
+ out.append(f"```{lang}\n{body}\n```\n")
159
+ pos = close_m.end()
160
+ else:
161
+ # Trailing open tag mid-stream — render what we have so far and stop.
162
+ body = text[body_start:]
163
+ out.append(_artifact_header(attrs, suffix=" *(generating…)*"))
164
+ out.append(f"```{lang}\n{body}\n```\n")
165
+ pos = len(text)
166
+ break
167
+ return "".join(out)
168
+
86
169
  _WRITE_TOOLS = {"write_file", "create_file", "fs_write"}
87
170
 
88
171
 
@@ -129,7 +212,7 @@ class TextSegment(Static):
129
212
 
130
213
  def append(self, chunk: str) -> None:
131
214
  self._buf += chunk
132
- self.update(Markdown(self._buf))
215
+ self.update(Markdown(normalize_artifacts(self._buf)))
133
216
 
134
217
  @property
135
218
  def content(self) -> str:
@@ -421,11 +504,14 @@ class AssistantTurn(Vertical):
421
504
 
422
505
  def __init__(self, *, show_thinking: bool = True) -> None:
423
506
  super().__init__()
424
- self._tools: dict[str, ToolCallCard] = {}
507
+ # Value is either the card widget OR None when the matching tool_start
508
+ # was suppressed (empty/truncated args) — see tui.py _handle_event.
509
+ self._tools: dict[str, Optional[Any]] = {}
425
510
  self._todo: Optional[TodoList] = None
426
511
  self._last_text: Optional[TextSegment] = None
427
512
  self._thinking: Optional[ThinkingIndicator] = None
428
513
  self._show_thinking = show_thinking
514
+ self._truncated_shown: bool = False
429
515
  self._final = False
430
516
 
431
517
  async def on_mount(self) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 0.7.2
3
+ Version: 0.7.5
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