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.
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/PKG-INFO +1 -1
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/pyproject.toml +1 -1
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/__init__.py +1 -1
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/tui.py +116 -25
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/widgets.py +88 -2
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/README.md +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/setup.cfg +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/ask.py +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/command.py +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/history.py +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/sse.py +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/executor.py +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/main.py +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/runner.py +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/src/virtuai_cli.egg-info/requires.txt +0 -0
- {virtuai_cli-0.7.2 → virtuai_cli-0.7.5}/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.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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|