virtuai-cli 0.2.1__tar.gz → 0.3.0__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.2.1 → virtuai_cli-0.3.0}/PKG-INFO +1 -1
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/pyproject.toml +1 -1
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/__init__.py +1 -1
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/chat/command.py +2 -1
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/chat/tui.py +93 -26
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/chat/widgets.py +3 -3
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/README.md +0 -0
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/setup.cfg +0 -0
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/chat/sse.py +0 -0
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/executor.py +0 -0
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/main.py +0 -0
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/runner.py +0 -0
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli.egg-info/requires.txt +0 -0
- {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli.egg-info/top_level.txt +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""VirtuAI local CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.3.0"
|
|
@@ -92,6 +92,7 @@ def run_chat(
|
|
|
92
92
|
agent_id=picked["agent_id"],
|
|
93
93
|
agent_name=picked["name"],
|
|
94
94
|
workspace_name=info["workspace_name"],
|
|
95
|
-
default_model_id=picked.get("default_model_id"),
|
|
95
|
+
default_model_id=picked.get("default_model_id") or None,
|
|
96
|
+
allowed_models=picked.get("allowed_models") or [],
|
|
96
97
|
)
|
|
97
98
|
app.run()
|
|
@@ -22,22 +22,20 @@ class ChatApp(App):
|
|
|
22
22
|
CSS = """
|
|
23
23
|
Screen { background: $surface; }
|
|
24
24
|
|
|
25
|
-
#conversation {
|
|
26
|
-
height: 1fr;
|
|
27
|
-
padding: 0 1;
|
|
28
|
-
scrollbar-gutter: stable;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
25
|
#status {
|
|
32
|
-
dock: top;
|
|
33
26
|
height: 1;
|
|
34
27
|
padding: 0 1;
|
|
35
28
|
background: $panel;
|
|
36
29
|
color: $text-muted;
|
|
37
30
|
}
|
|
38
31
|
|
|
32
|
+
#conversation {
|
|
33
|
+
height: 1fr;
|
|
34
|
+
padding: 0 1;
|
|
35
|
+
scrollbar-gutter: stable;
|
|
36
|
+
}
|
|
37
|
+
|
|
39
38
|
#input {
|
|
40
|
-
dock: bottom;
|
|
41
39
|
height: 3;
|
|
42
40
|
border: round $primary;
|
|
43
41
|
margin: 0 1 1 1;
|
|
@@ -66,6 +64,7 @@ class ChatApp(App):
|
|
|
66
64
|
agent_name: str,
|
|
67
65
|
workspace_name: str,
|
|
68
66
|
default_model_id: Optional[str] = None,
|
|
67
|
+
allowed_models: Optional[list[dict]] = None,
|
|
69
68
|
) -> None:
|
|
70
69
|
super().__init__()
|
|
71
70
|
self.server_url = server_url
|
|
@@ -75,6 +74,8 @@ class ChatApp(App):
|
|
|
75
74
|
self.agent_name = agent_name
|
|
76
75
|
self.workspace_name = workspace_name
|
|
77
76
|
self.default_model_id = default_model_id
|
|
77
|
+
self.current_model_id = default_model_id
|
|
78
|
+
self.allowed_models = list(allowed_models or [])
|
|
78
79
|
|
|
79
80
|
self.session_id: Optional[str] = None
|
|
80
81
|
self._stream_task: Optional[asyncio.Task] = None
|
|
@@ -96,7 +97,7 @@ class ChatApp(App):
|
|
|
96
97
|
yield Footer()
|
|
97
98
|
|
|
98
99
|
def _initial_status(self) -> str:
|
|
99
|
-
model = f" · {self.
|
|
100
|
+
model = f" · {self.current_model_id}" if self.current_model_id else ""
|
|
100
101
|
return f"{self.workspace_name} · {self.agent_name}{model} · connecting…"
|
|
101
102
|
|
|
102
103
|
# ── Mount: start the WS runner in the background ──────────────────────
|
|
@@ -108,18 +109,26 @@ class ChatApp(App):
|
|
|
108
109
|
|
|
109
110
|
async def _run_ws(self) -> None:
|
|
110
111
|
def on_status(level: str, msg: str) -> None:
|
|
111
|
-
#
|
|
112
|
-
self.
|
|
112
|
+
# Runner runs on the same event loop, so call directly.
|
|
113
|
+
self._set_status(msg)
|
|
113
114
|
|
|
114
115
|
try:
|
|
115
116
|
await ws_runner.run_forever(self.server_url, self.token, self.workdir, on_status=on_status)
|
|
117
|
+
except asyncio.CancelledError:
|
|
118
|
+
raise
|
|
116
119
|
except Exception as exc:
|
|
117
|
-
self.
|
|
120
|
+
self._set_status(f"runner error: {exc}")
|
|
118
121
|
|
|
119
122
|
def _set_status(self, text: str) -> None:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
# The status widget may have been torn down during app shutdown —
|
|
124
|
+
# swallow the lookup error so we don't surface a NoMatches on exit.
|
|
125
|
+
model = f" · {self.current_model_id}" if self.current_model_id else ""
|
|
126
|
+
try:
|
|
127
|
+
self.query_one("#status", Static).update(
|
|
128
|
+
f"{self.workspace_name} · {self.agent_name}{model} · {text}"
|
|
129
|
+
)
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
123
132
|
|
|
124
133
|
# ── Input submission ──────────────────────────────────────────────────
|
|
125
134
|
@on(Input.Submitted, "#input")
|
|
@@ -155,20 +164,72 @@ class ChatApp(App):
|
|
|
155
164
|
async def _handle_slash(self, cmd: str) -> None:
|
|
156
165
|
parts = cmd.split(None, 1)
|
|
157
166
|
head = parts[0].lower()
|
|
167
|
+
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
158
168
|
if head in ("/exit", "/quit"):
|
|
159
169
|
self.exit()
|
|
160
170
|
elif head in ("/clear", "/new"):
|
|
161
171
|
await self.action_clear_conversation()
|
|
172
|
+
elif head == "/models":
|
|
173
|
+
await self._show_models()
|
|
174
|
+
elif head == "/model":
|
|
175
|
+
await self._set_model(arg)
|
|
162
176
|
elif head == "/help":
|
|
163
177
|
await self._append(Static(
|
|
164
178
|
"[b]Commands[/b]\n"
|
|
165
|
-
" /help
|
|
166
|
-
" /clear, /new
|
|
167
|
-
" /
|
|
168
|
-
"
|
|
179
|
+
" /help this list\n"
|
|
180
|
+
" /clear, /new start a fresh conversation\n"
|
|
181
|
+
" /models list models available for this agent\n"
|
|
182
|
+
" /model <id> switch the model for the next message\n"
|
|
183
|
+
" /exit, /quit close the TUI\n"
|
|
184
|
+
" Esc cancel current response\n"
|
|
169
185
|
))
|
|
170
186
|
else:
|
|
171
|
-
await self._append(Static(f"[red]Unknown command: {head}[/red]"))
|
|
187
|
+
await self._append(Static(f"[red]Unknown command: {head}[/red] (try /help)"))
|
|
188
|
+
|
|
189
|
+
async def _show_models(self) -> None:
|
|
190
|
+
if not self.allowed_models:
|
|
191
|
+
await self._append(Static(
|
|
192
|
+
"[yellow]No model catalog available for this agent.[/yellow]"
|
|
193
|
+
))
|
|
194
|
+
return
|
|
195
|
+
lines = ["[b]Available models[/b]"]
|
|
196
|
+
for m in self.allowed_models:
|
|
197
|
+
marker = "•" if m["id"] != self.current_model_id else "[green]✓[/green]"
|
|
198
|
+
label = m.get("label") or m["id"]
|
|
199
|
+
provider = f" [dim]({m['provider']})[/dim]" if m.get("provider") else ""
|
|
200
|
+
lines.append(f" {marker} [bold]{m['id']}[/bold] — {label}{provider}")
|
|
201
|
+
lines.append("\n[dim]Use [/dim][b]/model <id>[/b][dim] to switch.[/dim]")
|
|
202
|
+
await self._append(Static("\n".join(lines)))
|
|
203
|
+
|
|
204
|
+
async def _set_model(self, requested: str) -> None:
|
|
205
|
+
if not requested:
|
|
206
|
+
current = self.current_model_id or "(none)"
|
|
207
|
+
await self._append(Static(
|
|
208
|
+
f"Current model: [b]{current}[/b]\n"
|
|
209
|
+
f"Usage: [b]/model <id>[/b] (see [b]/models[/b] for the list)"
|
|
210
|
+
))
|
|
211
|
+
return
|
|
212
|
+
if self._stream_task and not self._stream_task.done():
|
|
213
|
+
await self._append(Static(
|
|
214
|
+
"[yellow]Cancel the in-flight response (Esc) before switching models.[/yellow]"
|
|
215
|
+
))
|
|
216
|
+
return
|
|
217
|
+
if self.allowed_models:
|
|
218
|
+
valid_ids = {m["id"] for m in self.allowed_models}
|
|
219
|
+
if requested not in valid_ids:
|
|
220
|
+
# Loose match by label too
|
|
221
|
+
by_label = {m.get("label", ""): m["id"] for m in self.allowed_models}
|
|
222
|
+
if requested in by_label:
|
|
223
|
+
requested = by_label[requested]
|
|
224
|
+
else:
|
|
225
|
+
await self._append(Static(
|
|
226
|
+
f"[red]'{requested}' is not in this agent's catalog.[/red] "
|
|
227
|
+
f"Run [b]/models[/b] to see valid IDs."
|
|
228
|
+
))
|
|
229
|
+
return
|
|
230
|
+
self.current_model_id = requested
|
|
231
|
+
self._set_status(f"model set to {requested}")
|
|
232
|
+
await self._append(Static(f"[green]✓[/green] Model switched to [b]{requested}[/b]."))
|
|
172
233
|
|
|
173
234
|
async def action_clear_conversation(self) -> None:
|
|
174
235
|
self.session_id = None
|
|
@@ -195,19 +256,25 @@ class ChatApp(App):
|
|
|
195
256
|
self.agent_id,
|
|
196
257
|
message,
|
|
197
258
|
session_id=self.session_id,
|
|
198
|
-
model_id=self.
|
|
259
|
+
model_id=self.current_model_id,
|
|
199
260
|
):
|
|
200
261
|
await self._handle_event(event, turn)
|
|
201
262
|
except asyncio.CancelledError:
|
|
202
|
-
|
|
263
|
+
# App may be shutting down — don't touch widgets here.
|
|
203
264
|
raise
|
|
204
265
|
except Exception as exc:
|
|
205
|
-
|
|
206
|
-
|
|
266
|
+
try:
|
|
267
|
+
await turn.append_token(f"\n\n*[error: {exc}]*")
|
|
268
|
+
self._set_status(f"stream error: {exc}")
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
207
271
|
finally:
|
|
208
272
|
turn.mark_final()
|
|
209
|
-
|
|
210
|
-
|
|
273
|
+
try:
|
|
274
|
+
scroll = self.query_one("#conversation", VerticalScroll)
|
|
275
|
+
scroll.scroll_end(animate=False)
|
|
276
|
+
except Exception:
|
|
277
|
+
pass
|
|
211
278
|
|
|
212
279
|
async def _handle_event(self, event: dict, turn: AssistantTurn) -> None:
|
|
213
280
|
etype = event.get("type")
|
|
@@ -72,9 +72,9 @@ class ToolCallCard(Static):
|
|
|
72
72
|
self.input_str = input_str or ""
|
|
73
73
|
self.output_preview: Optional[str] = None
|
|
74
74
|
self.add_class("-running")
|
|
75
|
-
self.
|
|
75
|
+
self._redraw()
|
|
76
76
|
|
|
77
|
-
def
|
|
77
|
+
def _redraw(self) -> None:
|
|
78
78
|
marker = "✓" if "-done" in self.classes else ("✗" if "-error" in self.classes else "▶")
|
|
79
79
|
head = Text(f"{marker} {self.tool_name}", style="bold")
|
|
80
80
|
if self.input_str:
|
|
@@ -90,7 +90,7 @@ class ToolCallCard(Static):
|
|
|
90
90
|
self.output_preview = output_preview
|
|
91
91
|
self.remove_class("-running")
|
|
92
92
|
self.add_class("-error" if errored else "-done")
|
|
93
|
-
self.
|
|
93
|
+
self._redraw()
|
|
94
94
|
|
|
95
95
|
|
|
96
96
|
class TodoList(Static):
|
|
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
|