virtuai-cli 0.8.7__tar.gz → 0.8.9__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.8.7 → virtuai_cli-0.8.9}/PKG-INFO +31 -1
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/README.md +28 -0
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/pyproject.toml +9 -1
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/chat/ask.py +13 -2
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/chat/command.py +4 -2
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/chat/tui.py +341 -31
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/chat/widgets.py +329 -20
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/config.py +9 -0
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/main.py +20 -3
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/runner.py +87 -0
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli.egg-info/PKG-INFO +31 -1
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli.egg-info/requires.txt +3 -0
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/setup.cfg +0 -0
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/__init__.py +0 -0
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/chat/history.py +0 -0
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/chat/sse.py +0 -0
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/executor.py +0 -0
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/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.8.
|
|
3
|
+
Version: 0.8.9
|
|
4
4
|
Summary: Run VirtuAI deep agents on your local machine
|
|
5
5
|
Author-email: uCloudStore <lmoreno@ucloudstore.com>
|
|
6
6
|
License: Proprietary
|
|
@@ -20,6 +20,8 @@ Requires-Dist: keyring>=25.0
|
|
|
20
20
|
Requires-Dist: typer>=0.12
|
|
21
21
|
Requires-Dist: rich>=13.0
|
|
22
22
|
Requires-Dist: textual>=0.86
|
|
23
|
+
Provides-Extra: local
|
|
24
|
+
Requires-Dist: langchain-ollama>=0.2; extra == "local"
|
|
23
25
|
|
|
24
26
|
# VirtuAI CLI
|
|
25
27
|
|
|
@@ -37,6 +39,12 @@ Or with [pipx](https://pipx.pypa.io/) (recommended for CLI tools — handles PAT
|
|
|
37
39
|
pipx install virtuai-cli
|
|
38
40
|
```
|
|
39
41
|
|
|
42
|
+
For **local model inference** (running agents against [Ollama](https://ollama.com) on your machine — see below), install the `local` extra:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install 'virtuai-cli[local]' # or: pipx install 'virtuai-cli[local]'
|
|
46
|
+
```
|
|
47
|
+
|
|
40
48
|
Requires Python 3.11+.
|
|
41
49
|
|
|
42
50
|
## Quick start
|
|
@@ -71,6 +79,28 @@ virtuai ask --json "find any bugs" > events.jsonl
|
|
|
71
79
|
|
|
72
80
|
`ask` is non-interactive: it prints the response to stdout, then exits.
|
|
73
81
|
|
|
82
|
+
## Local model inference (Ollama)
|
|
83
|
+
|
|
84
|
+
Agents can run inference on **your own machine** via [Ollama](https://ollama.com) instead of a cloud provider — useful for cost, data locality, or running open models. Only the model turns are proxied to your machine over the CLI's WebSocket; the agent loop, tools, knowledge bases, and chat history stay on the server.
|
|
85
|
+
|
|
86
|
+
To use it:
|
|
87
|
+
|
|
88
|
+
1. Install the CLI with the local extra (pulls `langchain-ollama`):
|
|
89
|
+
```bash
|
|
90
|
+
pip install 'virtuai-cli[local]'
|
|
91
|
+
```
|
|
92
|
+
2. Install [Ollama](https://ollama.com), start it, and pull a **tool-capable** model:
|
|
93
|
+
```bash
|
|
94
|
+
ollama serve
|
|
95
|
+
ollama pull qwen3:8b # instruct/agentic models work best for tool calling
|
|
96
|
+
```
|
|
97
|
+
3. In the VirtuAI portal, set the agent's **Provider** to **Local (Ollama)**, enter the model tag you pulled, and pick a **cloud fallback** model.
|
|
98
|
+
4. Connect the CLI (`virtuai chat`, `virtuai ask`, or `virtuai run`) — model turns are now answered by your local Ollama.
|
|
99
|
+
|
|
100
|
+
The CLI talks to Ollama at `http://localhost:11434` by default; override with the `OLLAMA_BASE_URL` environment variable.
|
|
101
|
+
|
|
102
|
+
If the CLI isn't connected, Ollama isn't running, or the model isn't pulled, the agent **falls back to its configured cloud model** and shows a notice. For reliable tool use, prefer instruct/agentic models (e.g. `qwen3:*`, `qwen2.5:*`); some code-tuned or non-instruct models emit tool calls as plain text and won't drive the agent's tools.
|
|
103
|
+
|
|
74
104
|
## Commands
|
|
75
105
|
|
|
76
106
|
| Command | Description |
|
|
@@ -14,6 +14,12 @@ Or with [pipx](https://pipx.pypa.io/) (recommended for CLI tools — handles PAT
|
|
|
14
14
|
pipx install virtuai-cli
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
For **local model inference** (running agents against [Ollama](https://ollama.com) on your machine — see below), install the `local` extra:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install 'virtuai-cli[local]' # or: pipx install 'virtuai-cli[local]'
|
|
21
|
+
```
|
|
22
|
+
|
|
17
23
|
Requires Python 3.11+.
|
|
18
24
|
|
|
19
25
|
## Quick start
|
|
@@ -48,6 +54,28 @@ virtuai ask --json "find any bugs" > events.jsonl
|
|
|
48
54
|
|
|
49
55
|
`ask` is non-interactive: it prints the response to stdout, then exits.
|
|
50
56
|
|
|
57
|
+
## Local model inference (Ollama)
|
|
58
|
+
|
|
59
|
+
Agents can run inference on **your own machine** via [Ollama](https://ollama.com) instead of a cloud provider — useful for cost, data locality, or running open models. Only the model turns are proxied to your machine over the CLI's WebSocket; the agent loop, tools, knowledge bases, and chat history stay on the server.
|
|
60
|
+
|
|
61
|
+
To use it:
|
|
62
|
+
|
|
63
|
+
1. Install the CLI with the local extra (pulls `langchain-ollama`):
|
|
64
|
+
```bash
|
|
65
|
+
pip install 'virtuai-cli[local]'
|
|
66
|
+
```
|
|
67
|
+
2. Install [Ollama](https://ollama.com), start it, and pull a **tool-capable** model:
|
|
68
|
+
```bash
|
|
69
|
+
ollama serve
|
|
70
|
+
ollama pull qwen3:8b # instruct/agentic models work best for tool calling
|
|
71
|
+
```
|
|
72
|
+
3. In the VirtuAI portal, set the agent's **Provider** to **Local (Ollama)**, enter the model tag you pulled, and pick a **cloud fallback** model.
|
|
73
|
+
4. Connect the CLI (`virtuai chat`, `virtuai ask`, or `virtuai run`) — model turns are now answered by your local Ollama.
|
|
74
|
+
|
|
75
|
+
The CLI talks to Ollama at `http://localhost:11434` by default; override with the `OLLAMA_BASE_URL` environment variable.
|
|
76
|
+
|
|
77
|
+
If the CLI isn't connected, Ollama isn't running, or the model isn't pulled, the agent **falls back to its configured cloud model** and shows a notice. For reliable tool use, prefer instruct/agentic models (e.g. `qwen3:*`, `qwen2.5:*`); some code-tuned or non-instruct models emit tool calls as plain text and won't drive the agent's tools.
|
|
78
|
+
|
|
51
79
|
## Commands
|
|
52
80
|
|
|
53
81
|
| Command | Description |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "virtuai-cli"
|
|
7
|
-
version = "0.8.
|
|
7
|
+
version = "0.8.9"
|
|
8
8
|
description = "Run VirtuAI deep agents on your local machine"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -30,6 +30,14 @@ dependencies = [
|
|
|
30
30
|
"textual>=0.86",
|
|
31
31
|
]
|
|
32
32
|
|
|
33
|
+
# Optional: local model inference. Pulls langchain-ollama (+ langchain-core) so
|
|
34
|
+
# the CLI can answer the server's model_generate frames from a locally-running
|
|
35
|
+
# Ollama. Install with: pip install 'virtuai-cli[local]'
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
local = [
|
|
38
|
+
"langchain-ollama>=0.2",
|
|
39
|
+
]
|
|
40
|
+
|
|
33
41
|
[project.urls]
|
|
34
42
|
Homepage = "https://imvituai.com"
|
|
35
43
|
|
|
@@ -101,6 +101,8 @@ async def _run_ask(
|
|
|
101
101
|
final_session = session_id
|
|
102
102
|
final_text_parts: list[str] = []
|
|
103
103
|
exit_code = 0
|
|
104
|
+
total_input_tokens = 0
|
|
105
|
+
total_output_tokens = 0
|
|
104
106
|
|
|
105
107
|
try:
|
|
106
108
|
async for event in stream_chat(
|
|
@@ -134,17 +136,26 @@ async def _run_ask(
|
|
|
134
136
|
err.print(f"[dim]✓ {first[0][:80] if first else ''}[/dim]")
|
|
135
137
|
else:
|
|
136
138
|
err.print("[dim]✓ done[/dim]")
|
|
139
|
+
elif etype == "notice" and mode == "stream":
|
|
140
|
+
msg = event.get("message", "")
|
|
141
|
+
color = {"warn": "yellow", "error": "red"}.get(event.get("level"), "dim")
|
|
142
|
+
err.print(f"[{color}]⚠ {msg}[/{color}]")
|
|
137
143
|
elif etype == "error":
|
|
138
144
|
msg = event.get("error") or event.get("message") or "unknown"
|
|
139
145
|
err.print(f"[red]error: {msg}[/red]")
|
|
140
146
|
exit_code = 1
|
|
141
147
|
break
|
|
142
148
|
elif etype in ("done", "complete"):
|
|
149
|
+
if etype == "complete":
|
|
150
|
+
total_input_tokens += event.get("input_tokens") or 0
|
|
151
|
+
total_output_tokens += event.get("output_tokens") or 0
|
|
143
152
|
break
|
|
144
153
|
|
|
145
154
|
if mode == "stream":
|
|
146
155
|
sys.stdout.write("\n")
|
|
147
156
|
sys.stdout.flush()
|
|
157
|
+
if total_input_tokens or total_output_tokens:
|
|
158
|
+
err.print(f"[dim]↑{total_input_tokens} ↓{total_output_tokens} tokens[/dim]")
|
|
148
159
|
elif mode == "quiet":
|
|
149
160
|
sys.stdout.write("".join(final_text_parts))
|
|
150
161
|
if final_text_parts and not final_text_parts[-1].endswith("\n"):
|
|
@@ -176,9 +187,9 @@ def run_ask_command(
|
|
|
176
187
|
) -> None:
|
|
177
188
|
"""Entry point invoked by `virtuai ask` in main.py."""
|
|
178
189
|
server_url = (server or cfg.get_server_url()).rstrip("/")
|
|
179
|
-
token = cfg.load_token(cfg.
|
|
190
|
+
token = cfg.load_token(cfg._KEYRING_USER_TOKEN_KEY)
|
|
180
191
|
if not token:
|
|
181
|
-
err.print("[red]Not
|
|
192
|
+
err.print("[red]Not logged in.[/red] Run [bold]virtuai login[/bold] first.")
|
|
182
193
|
raise typer.Exit(1)
|
|
183
194
|
|
|
184
195
|
# Compose message from arg + stdin (if piped)
|
|
@@ -69,9 +69,9 @@ def run_chat(
|
|
|
69
69
|
) -> None:
|
|
70
70
|
"""Launch the chat TUI. Called by the `chat` command in main.py."""
|
|
71
71
|
server_url = (server or cfg.get_server_url()).rstrip("/")
|
|
72
|
-
token = cfg.load_token(cfg.
|
|
72
|
+
token = cfg.load_token(cfg._KEYRING_USER_TOKEN_KEY)
|
|
73
73
|
if not token:
|
|
74
|
-
console.print("[red]Not
|
|
74
|
+
console.print("[red]Not logged in.[/red] Run [bold]virtuai login[/bold] first.")
|
|
75
75
|
raise typer.Exit(1)
|
|
76
76
|
|
|
77
77
|
base = workdir or Path.cwd()
|
|
@@ -95,4 +95,6 @@ def run_chat(
|
|
|
95
95
|
default_model_id=picked.get("default_model_id") or None,
|
|
96
96
|
allowed_models=picked.get("allowed_models") or [],
|
|
97
97
|
)
|
|
98
|
+
saved_theme = cfg.get("theme", "textual-dark")
|
|
99
|
+
app.theme = saved_theme
|
|
98
100
|
app.run()
|
|
@@ -23,7 +23,14 @@ from virtuai_cli.chat.sse import (
|
|
|
23
23
|
stream_chat,
|
|
24
24
|
stream_chat_multimodal,
|
|
25
25
|
)
|
|
26
|
-
from virtuai_cli.chat.widgets import
|
|
26
|
+
from virtuai_cli.chat.widgets import (
|
|
27
|
+
ApprovalPrompt,
|
|
28
|
+
AssistantTurn,
|
|
29
|
+
ChatInput,
|
|
30
|
+
ExpandableCard,
|
|
31
|
+
TextSegment,
|
|
32
|
+
UserBubble,
|
|
33
|
+
)
|
|
27
34
|
|
|
28
35
|
|
|
29
36
|
# Slash command catalog — single source of truth used by /help, the
|
|
@@ -41,6 +48,8 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
|
|
|
41
48
|
("/load", "load a past conversation: /load <session_id>"),
|
|
42
49
|
("/models", "list models available for this agent"),
|
|
43
50
|
("/model", "switch the model: /model <id>"),
|
|
51
|
+
("/connect", "connect a Google Workspace account: /connect <service>"),
|
|
52
|
+
("/accounts", "list your connected Google accounts"),
|
|
44
53
|
("/exit", "close the TUI"),
|
|
45
54
|
("/quit", "close the TUI"),
|
|
46
55
|
]
|
|
@@ -91,6 +100,7 @@ class ChatApp(App):
|
|
|
91
100
|
Binding("ctrl+y", "copy_last", "Copy last", show=True, priority=True),
|
|
92
101
|
Binding("f2", "toggle_select_mode", "Select", show=True, priority=True),
|
|
93
102
|
Binding("tab", "complete_slash", "Complete", show=False, priority=True),
|
|
103
|
+
Binding("ctrl+e", "toggle_steps", "Expand steps", show=True, priority=True),
|
|
94
104
|
]
|
|
95
105
|
|
|
96
106
|
status_line = reactive("")
|
|
@@ -134,6 +144,11 @@ class ChatApp(App):
|
|
|
134
144
|
# Buffer for execute_output chunks that arrive before the matching
|
|
135
145
|
# tool_start SSE event has mounted the tool card.
|
|
136
146
|
self._streaming_output: dict[str, list[str]] = {}
|
|
147
|
+
# Cumulative token usage for this session, from `complete` SSE events.
|
|
148
|
+
self._session_input_tokens: int = 0
|
|
149
|
+
self._session_output_tokens: int = 0
|
|
150
|
+
# Live command-approval prompt, keyed by request_id.
|
|
151
|
+
self._approval_prompts: dict[str, ApprovalPrompt] = {}
|
|
137
152
|
|
|
138
153
|
# ── Layout ────────────────────────────────────────────────────────────
|
|
139
154
|
def compose(self) -> ComposeResult:
|
|
@@ -177,12 +192,19 @@ class ChatApp(App):
|
|
|
177
192
|
else:
|
|
178
193
|
work_part = ""
|
|
179
194
|
|
|
195
|
+
total = self._session_input_tokens + self._session_output_tokens
|
|
196
|
+
tok_part = f" · [dim]{total:,} tokens[/dim]" if total else ""
|
|
197
|
+
|
|
180
198
|
msg_part = f" · {self._status_message}" if self._status_message else ""
|
|
181
199
|
return (
|
|
182
200
|
f"{self.workspace_name} · {self.agent_name}{model} · "
|
|
183
|
-
f"{conn_part}{work_part}{msg_part}"
|
|
201
|
+
f"{conn_part}{work_part}{tok_part}{msg_part}"
|
|
184
202
|
)
|
|
185
203
|
|
|
204
|
+
def watch_theme(self, theme: str) -> None:
|
|
205
|
+
from virtuai_cli import config as _cfg
|
|
206
|
+
_cfg.set_key("theme", theme)
|
|
207
|
+
|
|
186
208
|
# ── Mount: start the WS runner in the background ──────────────────────
|
|
187
209
|
async def on_mount(self) -> None:
|
|
188
210
|
self.title = "VirtuAI"
|
|
@@ -299,6 +321,26 @@ class ChatApp(App):
|
|
|
299
321
|
first_line = value.split("\n", 1)[0]
|
|
300
322
|
self._refresh_slash_hints(first_line if "\n" not in value else "")
|
|
301
323
|
|
|
324
|
+
def action_toggle_steps(self) -> None:
|
|
325
|
+
"""Ctrl+E — expand or collapse every step card in the conversation.
|
|
326
|
+
|
|
327
|
+
Individual cards can also be toggled by clicking them. The new state is
|
|
328
|
+
the opposite of whether *any* card is currently expanded, so one press
|
|
329
|
+
always produces a consistent view.
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
cards = list(self.query(ExpandableCard))
|
|
333
|
+
except Exception:
|
|
334
|
+
return
|
|
335
|
+
if not cards:
|
|
336
|
+
self._set_status("no steps to expand")
|
|
337
|
+
return
|
|
338
|
+
expand = not any(c.expanded for c in cards)
|
|
339
|
+
for card in cards:
|
|
340
|
+
card.expanded = expand
|
|
341
|
+
card._redraw()
|
|
342
|
+
self._set_status(f"steps {'expanded' if expand else 'collapsed'}")
|
|
343
|
+
|
|
302
344
|
def action_complete_slash(self) -> None:
|
|
303
345
|
"""Tab — complete the current slash command from the hint list."""
|
|
304
346
|
try:
|
|
@@ -361,6 +403,66 @@ class ChatApp(App):
|
|
|
361
403
|
await scroll.mount(widget)
|
|
362
404
|
scroll.scroll_end(animate=False)
|
|
363
405
|
|
|
406
|
+
# ── Command approvals ─────────────────────────────────────────────────
|
|
407
|
+
def _set_input_enabled(self, enabled: bool) -> None:
|
|
408
|
+
"""Disable the composer while an approval prompt owns the keyboard."""
|
|
409
|
+
try:
|
|
410
|
+
inp = self.query_one(ChatInput)
|
|
411
|
+
except Exception:
|
|
412
|
+
return
|
|
413
|
+
inp.disabled = not enabled
|
|
414
|
+
if enabled:
|
|
415
|
+
inp.focus()
|
|
416
|
+
|
|
417
|
+
@on(ApprovalPrompt.Decision)
|
|
418
|
+
async def _on_approval_decision(self, message: ApprovalPrompt.Decision) -> None:
|
|
419
|
+
"""Send the user's choice to the server and unblock the waiting command."""
|
|
420
|
+
prompt = self._approval_prompts.pop(message.request_id, None)
|
|
421
|
+
scope = message.scope if message.approved else "once"
|
|
422
|
+
payload = {
|
|
423
|
+
"approved": message.approved,
|
|
424
|
+
"scope": scope,
|
|
425
|
+
"duration_minutes": message.duration_minutes,
|
|
426
|
+
}
|
|
427
|
+
try:
|
|
428
|
+
import httpx
|
|
429
|
+
resp = await asyncio.to_thread(
|
|
430
|
+
lambda: httpx.post(
|
|
431
|
+
f"{self.server_url}/api/web-chat/approvals/{message.request_id}/decision",
|
|
432
|
+
params={"agent_id": self.agent_id},
|
|
433
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
434
|
+
json=payload,
|
|
435
|
+
timeout=15,
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
resp.raise_for_status()
|
|
439
|
+
except Exception as exc:
|
|
440
|
+
from rich.markup import escape
|
|
441
|
+
await self._append(Static(
|
|
442
|
+
f"[red]Failed to send approval decision: {escape(str(exc))}[/red]"
|
|
443
|
+
))
|
|
444
|
+
if prompt is not None:
|
|
445
|
+
prompt.mark_stale("could not be sent")
|
|
446
|
+
if not self._approval_prompts:
|
|
447
|
+
self._set_input_enabled(True)
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
if prompt is not None:
|
|
451
|
+
if message.approved:
|
|
452
|
+
# Nothing left to say — the tool card below shows the command
|
|
453
|
+
# running, so the prompt just gets out of the way.
|
|
454
|
+
try:
|
|
455
|
+
await prompt.remove()
|
|
456
|
+
except Exception:
|
|
457
|
+
pass
|
|
458
|
+
else:
|
|
459
|
+
# Keep a one-line trace so the agent's "I couldn't run that"
|
|
460
|
+
# reply has visible context.
|
|
461
|
+
prompt.mark_stale("denied")
|
|
462
|
+
# Hand the keyboard back so the user can keep typing.
|
|
463
|
+
if not self._approval_prompts:
|
|
464
|
+
self._set_input_enabled(True)
|
|
465
|
+
|
|
364
466
|
# ── Slash commands ────────────────────────────────────────────────────
|
|
365
467
|
async def _handle_slash(self, cmd: str) -> None:
|
|
366
468
|
parts = cmd.split(None, 1)
|
|
@@ -388,6 +490,10 @@ class ChatApp(App):
|
|
|
388
490
|
await self._show_attachments()
|
|
389
491
|
elif head == "/detach":
|
|
390
492
|
await self._detach_file(arg)
|
|
493
|
+
elif head == "/connect":
|
|
494
|
+
await self._connect_google(arg)
|
|
495
|
+
elif head == "/accounts":
|
|
496
|
+
await self._show_accounts()
|
|
391
497
|
elif head == "/help":
|
|
392
498
|
await self._append(Static(
|
|
393
499
|
"[b]Commands[/b]\n"
|
|
@@ -402,6 +508,8 @@ class ChatApp(App):
|
|
|
402
508
|
" /load <id> load a past conversation by session_id\n"
|
|
403
509
|
" /models list models available for this agent\n"
|
|
404
510
|
" /model <id> switch the model for the next message\n"
|
|
511
|
+
" /connect <service> connect a Google account (e.g. /connect google_gmail)\n"
|
|
512
|
+
" /accounts list connected Google accounts\n"
|
|
405
513
|
" /exit, /quit close the TUI\n"
|
|
406
514
|
"\n"
|
|
407
515
|
"[b]Keys[/b]\n"
|
|
@@ -412,6 +520,11 @@ class ChatApp(App):
|
|
|
412
520
|
" Ctrl+Y copy last assistant response\n"
|
|
413
521
|
" F2 toggle select mode (mouse selection on/off)\n"
|
|
414
522
|
" Tab autocomplete slash command\n"
|
|
523
|
+
" Ctrl+E expand/collapse all step cards\n"
|
|
524
|
+
"\n"
|
|
525
|
+
"[b]Step cards[/b]\n"
|
|
526
|
+
" Click any step card to expand it and see the full command\n"
|
|
527
|
+
" and output. Click again to collapse. [b]Ctrl+E[/b] toggles all.\n"
|
|
415
528
|
"\n"
|
|
416
529
|
"[b]Selecting text with the mouse[/b]\n"
|
|
417
530
|
" Press [b]F2[/b] (or run [b]/select[/b]) to release the TUI's mouse\n"
|
|
@@ -422,6 +535,112 @@ class ChatApp(App):
|
|
|
422
535
|
else:
|
|
423
536
|
await self._append(Static(f"[red]Unknown command: {head}[/red] (try /help)"))
|
|
424
537
|
|
|
538
|
+
async def _connect_google(self, service: str) -> None:
|
|
539
|
+
"""Open browser to connect a Google Workspace account via OAuth2."""
|
|
540
|
+
known = {
|
|
541
|
+
"google_gmail": "Gmail",
|
|
542
|
+
"google_drive": "Google Drive",
|
|
543
|
+
"google_calendar": "Google Calendar",
|
|
544
|
+
"google_chat": "Google Chat",
|
|
545
|
+
"google_people": "Google Contacts",
|
|
546
|
+
}
|
|
547
|
+
if not service:
|
|
548
|
+
lines = ["[b]Available Google services:[/b]"]
|
|
549
|
+
for k, v in known.items():
|
|
550
|
+
lines.append(f" /connect [cyan]{k}[/cyan] → {v}")
|
|
551
|
+
await self._append(Static("\n".join(lines)))
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
label = known.get(service, service)
|
|
555
|
+
await self._append(Static(f"[dim]Connecting to {label}…[/dim]"))
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
import httpx
|
|
559
|
+
resp = httpx.get(
|
|
560
|
+
f"{self.server_url}/api/web-chat/oauth/google/authorize",
|
|
561
|
+
params={"predefined_type": service, "agent_id": self.agent_id},
|
|
562
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
563
|
+
timeout=10,
|
|
564
|
+
)
|
|
565
|
+
resp.raise_for_status()
|
|
566
|
+
auth_url = resp.json().get("auth_url", "")
|
|
567
|
+
except Exception as e:
|
|
568
|
+
from rich.markup import escape
|
|
569
|
+
await self._append(Static(f"[red]Failed to get authorization URL: {escape(str(e))}[/red]"))
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
await self._append(Static(
|
|
573
|
+
f"[b]Connect {label}[/b]\n"
|
|
574
|
+
f"Open this URL in your browser:"
|
|
575
|
+
))
|
|
576
|
+
# OAuth URLs contain characters Rich parses as markup — render raw.
|
|
577
|
+
await self._append(Static(auth_url, markup=False))
|
|
578
|
+
await self._append(Static(
|
|
579
|
+
"[dim]After authorizing, your account will be connected automatically.[/dim]"
|
|
580
|
+
))
|
|
581
|
+
|
|
582
|
+
import webbrowser
|
|
583
|
+
try:
|
|
584
|
+
webbrowser.open(auth_url)
|
|
585
|
+
await self._append(Static("[dim]Browser opened (if available).[/dim]"))
|
|
586
|
+
except Exception:
|
|
587
|
+
pass
|
|
588
|
+
|
|
589
|
+
# Poll for token for up to 90 seconds
|
|
590
|
+
await self._append(Static("[dim]Waiting for authorization (up to 90 s)…[/dim]"))
|
|
591
|
+
for _ in range(30):
|
|
592
|
+
await asyncio.sleep(3)
|
|
593
|
+
try:
|
|
594
|
+
check = httpx.get(
|
|
595
|
+
f"{self.server_url}/api/web-chat/oauth/tokens",
|
|
596
|
+
params={"agent_id": self.agent_id},
|
|
597
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
598
|
+
timeout=5,
|
|
599
|
+
)
|
|
600
|
+
tokens = check.json() if check.is_success else []
|
|
601
|
+
match = next((t for t in tokens if t.get("service") == service), None)
|
|
602
|
+
if match and match.get("status") == "connected":
|
|
603
|
+
email = match.get("connected_email", "your account")
|
|
604
|
+
await self._append(Static(f"[green]✓ Connected as {email}[/green]"))
|
|
605
|
+
return
|
|
606
|
+
except Exception:
|
|
607
|
+
pass
|
|
608
|
+
|
|
609
|
+
await self._append(Static("[yellow]Timed out waiting for authorization. Try again or check the browser.[/yellow]"))
|
|
610
|
+
|
|
611
|
+
async def _show_accounts(self) -> None:
|
|
612
|
+
"""List connected Google accounts for this user."""
|
|
613
|
+
try:
|
|
614
|
+
import httpx
|
|
615
|
+
resp = httpx.get(
|
|
616
|
+
f"{self.server_url}/api/web-chat/oauth/tokens",
|
|
617
|
+
params={"agent_id": self.agent_id},
|
|
618
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
619
|
+
timeout=10,
|
|
620
|
+
)
|
|
621
|
+
resp.raise_for_status()
|
|
622
|
+
tokens = resp.json()
|
|
623
|
+
except Exception as e:
|
|
624
|
+
from rich.markup import escape
|
|
625
|
+
await self._append(Static(f"[red]Failed to load accounts: {escape(str(e))}[/red]"))
|
|
626
|
+
return
|
|
627
|
+
|
|
628
|
+
if not tokens:
|
|
629
|
+
await self._append(Static(
|
|
630
|
+
"[yellow]No Google accounts connected.[/yellow] "
|
|
631
|
+
"Use [cyan]/connect google_gmail[/cyan] (or other services) to connect."
|
|
632
|
+
))
|
|
633
|
+
return
|
|
634
|
+
|
|
635
|
+
lines = ["[b]Connected Google accounts:[/b]"]
|
|
636
|
+
for t in tokens:
|
|
637
|
+
name = t.get("display_name") or t.get("service", "")
|
|
638
|
+
email = t.get("connected_email", "")
|
|
639
|
+
status = "✓" if t.get("status") == "connected" else "⚠ expired"
|
|
640
|
+
color = "green" if t.get("status") == "connected" else "yellow"
|
|
641
|
+
lines.append(f" [{color}]{status}[/{color}] {name} [dim]{email}[/dim]")
|
|
642
|
+
await self._append(Static("\n".join(lines)))
|
|
643
|
+
|
|
425
644
|
async def _show_models(self) -> None:
|
|
426
645
|
if not self.allowed_models:
|
|
427
646
|
await self._append(Static(
|
|
@@ -833,48 +1052,139 @@ class ChatApp(App):
|
|
|
833
1052
|
# story. Empty == "", "{}", or anything that strips to nothing.
|
|
834
1053
|
input_val = event.get("input", "")
|
|
835
1054
|
input_str = input_val if isinstance(input_val, str) else json.dumps(input_val, default=str)
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1055
|
+
raw_tool_id = event.get("id", "")
|
|
1056
|
+
subagent_id = event.get("subagent_id")
|
|
1057
|
+
if subagent_id:
|
|
1058
|
+
# Nested step — belongs to a sub-agent. Prefix and indent it.
|
|
1059
|
+
agent_name = turn._subagent_names.get(subagent_id, "sub-agent")
|
|
1060
|
+
tool_id = f"{subagent_id}:{raw_tool_id}"
|
|
1061
|
+
if input_str.strip() in ("", "{}", "[]"):
|
|
1062
|
+
turn._tools[tool_id] = None # type: ignore[assignment]
|
|
1063
|
+
else:
|
|
1064
|
+
await turn.start_tool(
|
|
1065
|
+
tool_id,
|
|
1066
|
+
f"[{agent_name}] {event.get('name', 'tool')}",
|
|
1067
|
+
input_val,
|
|
1068
|
+
display=event.get("display", ""),
|
|
1069
|
+
file_content=event.get("file_content"),
|
|
1070
|
+
file_content_before=event.get("file_content_before"),
|
|
1071
|
+
read_offset=event.get("read_offset"),
|
|
1072
|
+
read_limit=event.get("read_limit"),
|
|
1073
|
+
indent=True,
|
|
1074
|
+
)
|
|
839
1075
|
else:
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1076
|
+
tool_id = raw_tool_id
|
|
1077
|
+
if input_str.strip() in ("", "{}", "[]"):
|
|
1078
|
+
turn._tools[tool_id] = None # type: ignore[assignment]
|
|
1079
|
+
else:
|
|
1080
|
+
await turn.start_tool(
|
|
1081
|
+
tool_id,
|
|
1082
|
+
event.get("name", "tool"),
|
|
1083
|
+
input_val,
|
|
1084
|
+
display=event.get("display", ""),
|
|
1085
|
+
file_content=event.get("file_content"),
|
|
1086
|
+
file_content_before=event.get("file_content_before"),
|
|
1087
|
+
read_offset=event.get("read_offset"),
|
|
1088
|
+
read_limit=event.get("read_limit"),
|
|
1089
|
+
)
|
|
1090
|
+
# Flush any execute_output chunks that raced ahead of this
|
|
1091
|
+
# tool_start event (can happen when a subprocess starts
|
|
1092
|
+
# printing immediately, e.g. a fast SSH connection).
|
|
1093
|
+
card = turn._tools.get(tool_id)
|
|
1094
|
+
if card is not None:
|
|
1095
|
+
for buffered_chunk in self._streaming_output.pop(tool_id, []):
|
|
1096
|
+
if hasattr(card, "append_output"):
|
|
1097
|
+
card.append_output(buffered_chunk)
|
|
858
1098
|
elif etype == "tool_end":
|
|
859
1099
|
# If we skipped the matching tool_start (empty input), don't try
|
|
860
1100
|
# to finalize a card that doesn't exist.
|
|
861
|
-
|
|
862
|
-
|
|
1101
|
+
raw_tool_id = event.get("id", "")
|
|
1102
|
+
subagent_id = event.get("subagent_id")
|
|
1103
|
+
tool_id = f"{subagent_id}:{raw_tool_id}" if subagent_id else raw_tool_id
|
|
1104
|
+
if turn._tools.get(tool_id) is not None:
|
|
1105
|
+
turn.finish_tool(tool_id, event.get("output_preview"), errored=False)
|
|
1106
|
+
elif etype == "approval_request":
|
|
1107
|
+
request_id = event.get("request_id", "")
|
|
1108
|
+
if request_id and request_id not in self._approval_prompts:
|
|
1109
|
+
prompt = ApprovalPrompt(
|
|
1110
|
+
request_id,
|
|
1111
|
+
event.get("command", ""),
|
|
1112
|
+
int(event.get("timeout_secs", 300)),
|
|
1113
|
+
)
|
|
1114
|
+
self._approval_prompts[request_id] = prompt
|
|
1115
|
+
await self._append(prompt)
|
|
1116
|
+
# A focused TextArea swallows printable keys before any
|
|
1117
|
+
# binding runs, so the choice keys only reach the prompt if
|
|
1118
|
+
# the input isn't focused. The input is unusable anyway while
|
|
1119
|
+
# the turn is blocked on this decision.
|
|
1120
|
+
self._set_input_enabled(False)
|
|
1121
|
+
prompt.focus()
|
|
1122
|
+
elif etype == "approval_resolved":
|
|
1123
|
+
request_id = event.get("request_id", "")
|
|
1124
|
+
prompt = self._approval_prompts.pop(request_id, None)
|
|
1125
|
+
if prompt is not None and not prompt._answered:
|
|
1126
|
+
prompt.mark_stale("expired")
|
|
1127
|
+
if not self._approval_prompts:
|
|
1128
|
+
self._set_input_enabled(True)
|
|
863
1129
|
elif etype == "todos":
|
|
864
1130
|
await turn.set_todos(event.get("items", []))
|
|
865
1131
|
elif etype == "truncated":
|
|
866
|
-
# Dedupe:
|
|
867
|
-
#
|
|
868
|
-
# we don't want a wall of `[response truncated]` markers.
|
|
1132
|
+
# Dedupe: only show once per turn — multiple model calls in one turn
|
|
1133
|
+
# (e.g. tool retries) can each emit this, but one notice is enough.
|
|
869
1134
|
if not turn._truncated_shown:
|
|
870
1135
|
await turn.append_token(
|
|
871
|
-
"\n\n*[
|
|
1136
|
+
"\n\n*[output cut off — max output tokens reached. Ask the agent to continue.]*"
|
|
872
1137
|
)
|
|
873
1138
|
turn._truncated_shown = True
|
|
1139
|
+
elif etype == "notice":
|
|
1140
|
+
msg = event.get("message") or ""
|
|
1141
|
+
if msg:
|
|
1142
|
+
await turn.append_token(f"\n\n*⚠ {msg}*\n\n")
|
|
874
1143
|
elif etype == "error":
|
|
875
1144
|
msg = event.get("error") or event.get("message") or "unknown error"
|
|
876
1145
|
await turn.append_token(f"\n\n*[error: {msg}]*")
|
|
877
1146
|
elif etype in ("done", "complete"):
|
|
1147
|
+
if etype == "complete":
|
|
1148
|
+
inp = event.get("input_tokens") or 0
|
|
1149
|
+
out = event.get("output_tokens") or 0
|
|
1150
|
+
if inp or out:
|
|
1151
|
+
self._session_input_tokens += inp
|
|
1152
|
+
self._session_output_tokens += out
|
|
1153
|
+
await turn.set_usage(inp, out)
|
|
1154
|
+
self._refresh_status_bar()
|
|
878
1155
|
return
|
|
879
|
-
|
|
880
|
-
|
|
1156
|
+
elif etype == "subagent_start":
|
|
1157
|
+
delegation_id = event.get("id", "")
|
|
1158
|
+
raw_name = event.get("name", "")
|
|
1159
|
+
agent_name = raw_name.replace("delegate_to_", "")
|
|
1160
|
+
# Record the delegation_id → name mapping so nested tool_start
|
|
1161
|
+
# events with subagent_id can label and indent their cards.
|
|
1162
|
+
turn._subagent_names[delegation_id] = agent_name
|
|
1163
|
+
task = event.get("input", "")
|
|
1164
|
+
tool_id = f"subagent:{delegation_id}"
|
|
1165
|
+
await turn.start_tool(tool_id, f"🤖 {agent_name}", task)
|
|
1166
|
+
elif etype == "subagent_end":
|
|
1167
|
+
tool_id = f"subagent:{event.get('id', '')}"
|
|
1168
|
+
turn.finish_tool(tool_id, event.get("output_preview"), errored=False)
|
|
1169
|
+
elif etype == "subagent_step":
|
|
1170
|
+
subagent_name = event.get("subagent_name", "sub-agent")
|
|
1171
|
+
step = event.get("step") or {}
|
|
1172
|
+
stype = step.get("type", "")
|
|
1173
|
+
# Prefix tool IDs so sub-agent tool cards don't collide with parent ones.
|
|
1174
|
+
tool_id = f"{subagent_name}:{step.get('id', '')}"
|
|
1175
|
+
if stype == "tool_start":
|
|
1176
|
+
input_val = step.get("input", "")
|
|
1177
|
+
input_str = input_val if isinstance(input_val, str) else json.dumps(input_val, default=str)
|
|
1178
|
+
if input_str.strip() not in ("", "{}", "[]"):
|
|
1179
|
+
await turn.start_tool(
|
|
1180
|
+
tool_id,
|
|
1181
|
+
f"[{subagent_name}] {step.get('name', 'tool')}",
|
|
1182
|
+
input_val,
|
|
1183
|
+
display=step.get("display", ""),
|
|
1184
|
+
)
|
|
1185
|
+
elif stype == "tool_end":
|
|
1186
|
+
if turn._tools.get(tool_id) is not None:
|
|
1187
|
+
turn.finish_tool(tool_id, step.get("output_preview"), errored=False)
|
|
1188
|
+
elif stype == "todos":
|
|
1189
|
+
await turn.set_todos(step.get("items", []))
|
|
1190
|
+
# Other event types (skill_loaded, memory_updated, etc.) are silently ignored.
|