virtuai-cli 0.7.3__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.3 → virtuai_cli-0.7.5}/PKG-INFO +1 -1
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/pyproject.toml +1 -1
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli/__init__.py +1 -1
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/tui.py +116 -25
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/widgets.py +4 -1
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/README.md +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/setup.cfg +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/ask.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/command.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/history.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli/chat/sse.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli/executor.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli/main.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli/runner.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-0.7.3 → virtuai_cli-0.7.5}/src/virtuai_cli.egg-info/requires.txt +0 -0
- {virtuai_cli-0.7.3 → 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}]*")
|
|
@@ -504,11 +504,14 @@ class AssistantTurn(Vertical):
|
|
|
504
504
|
|
|
505
505
|
def __init__(self, *, show_thinking: bool = True) -> None:
|
|
506
506
|
super().__init__()
|
|
507
|
-
|
|
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]] = {}
|
|
508
510
|
self._todo: Optional[TodoList] = None
|
|
509
511
|
self._last_text: Optional[TextSegment] = None
|
|
510
512
|
self._thinking: Optional[ThinkingIndicator] = None
|
|
511
513
|
self._show_thinking = show_thinking
|
|
514
|
+
self._truncated_shown: bool = False
|
|
512
515
|
self._final = False
|
|
513
516
|
|
|
514
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
|