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.
Files changed (21) hide show
  1. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/PKG-INFO +1 -1
  2. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/pyproject.toml +1 -1
  3. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/__init__.py +1 -1
  4. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/chat/command.py +2 -1
  5. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/chat/tui.py +93 -26
  6. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/chat/widgets.py +3 -3
  7. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
  8. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/README.md +0 -0
  9. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/setup.cfg +0 -0
  10. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/chat/__init__.py +0 -0
  11. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/chat/sse.py +0 -0
  12. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/config.py +0 -0
  13. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/executor.py +0 -0
  14. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/main.py +0 -0
  15. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/runner.py +0 -0
  16. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli/security.py +0 -0
  17. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
  18. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  19. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  20. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/src/virtuai_cli.egg-info/requires.txt +0 -0
  21. {virtuai_cli-0.2.1 → virtuai_cli-0.3.0}/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.2.1
3
+ Version: 0.3.0
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.2.1"
7
+ version = "0.3.0"
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.2.1"
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.default_model_id}" if self.default_model_id else ""
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
- # Called from the runner; mutate the status bar safely.
112
- self.call_from_thread(self._set_status, f"{msg}")
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.call_from_thread(self._set_status, f"runner error: {exc}")
120
+ self._set_status(f"runner error: {exc}")
118
121
 
119
122
  def _set_status(self, text: str) -> None:
120
- self.query_one("#status", Static).update(
121
- f"{self.workspace_name} · {self.agent_name} · {text}"
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 this list\n"
166
- " /clear, /new start a fresh conversation\n"
167
- " /exit, /quit close the TUI\n"
168
- " Esc cancel current response\n"
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.default_model_id,
259
+ model_id=self.current_model_id,
199
260
  ):
200
261
  await self._handle_event(event, turn)
201
262
  except asyncio.CancelledError:
202
- self._set_status("response cancelled")
263
+ # App may be shutting down — don't touch widgets here.
203
264
  raise
204
265
  except Exception as exc:
205
- await turn.append_token(f"\n\n*[error: {exc}]*")
206
- self._set_status(f"stream error: {exc}")
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
- scroll = self.query_one("#conversation", VerticalScroll)
210
- scroll.scroll_end(animate=False)
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._render()
75
+ self._redraw()
76
76
 
77
- def _render(self) -> None:
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._render()
93
+ self._redraw()
94
94
 
95
95
 
96
96
  class TodoList(Static):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 0.2.1
3
+ Version: 0.3.0
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