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.
Files changed (23) hide show
  1. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/PKG-INFO +31 -1
  2. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/README.md +28 -0
  3. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/pyproject.toml +9 -1
  4. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/chat/ask.py +13 -2
  5. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/chat/command.py +4 -2
  6. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/chat/tui.py +341 -31
  7. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/chat/widgets.py +329 -20
  8. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/config.py +9 -0
  9. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/main.py +20 -3
  10. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/runner.py +87 -0
  11. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli.egg-info/PKG-INFO +31 -1
  12. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli.egg-info/requires.txt +3 -0
  13. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/setup.cfg +0 -0
  14. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/__init__.py +0 -0
  15. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/chat/__init__.py +0 -0
  16. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/chat/history.py +0 -0
  17. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/chat/sse.py +0 -0
  18. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/executor.py +0 -0
  19. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli/security.py +0 -0
  20. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
  21. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  22. {virtuai_cli-0.8.7 → virtuai_cli-0.8.9}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  23. {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.7
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"
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._KEYRING_CLI_TOKEN_KEY)
190
+ token = cfg.load_token(cfg._KEYRING_USER_TOKEN_KEY)
180
191
  if not token:
181
- err.print("[red]Not paired.[/red] Run [bold]virtuai pair <code>[/bold] first.")
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._KEYRING_CLI_TOKEN_KEY)
72
+ token = cfg.load_token(cfg._KEYRING_USER_TOKEN_KEY)
73
73
  if not token:
74
- console.print("[red]Not paired.[/red] Run [bold]virtuai pair <code>[/bold] first.")
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 AssistantTurn, ChatInput, TextSegment, UserBubble
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
- tool_id = event.get("id", "")
837
- if input_str.strip() in ("", "{}", "[]"):
838
- turn._tools[tool_id] = None # type: ignore[assignment]
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
- await turn.start_tool(
841
- tool_id,
842
- event.get("name", "tool"),
843
- input_val,
844
- display=event.get("display", ""),
845
- file_content=event.get("file_content"),
846
- file_content_before=event.get("file_content_before"),
847
- read_offset=event.get("read_offset"),
848
- read_limit=event.get("read_limit"),
849
- )
850
- # Flush any execute_output chunks that raced ahead of this
851
- # tool_start event (can happen when a subprocess starts
852
- # printing immediately, e.g. a fast SSH connection).
853
- card = turn._tools.get(tool_id)
854
- if card is not None:
855
- for buffered_chunk in self._streaming_output.pop(tool_id, []):
856
- if hasattr(card, "append_output"):
857
- card.append_output(buffered_chunk)
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
- if turn._tools.get(event.get("id", "")) is not None:
862
- turn.finish_tool(event.get("id", ""), event.get("output_preview"), errored=False)
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: when max_tokens hits repeatedly on multiple model passes
867
- # in a single turn (e.g. while a big tool-call arg gets retried),
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*[response truncatedtry raising the agent's max_tokens]*"
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
- # Other event types (subagent_*, skill_loaded, memory_updated, etc.)
880
- # are silently ignored in v1.
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.