virtuai-cli 0.6.2__tar.gz → 0.7.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 (23) hide show
  1. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/PKG-INFO +2 -2
  2. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/pyproject.toml +2 -2
  3. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/__init__.py +1 -1
  4. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/chat/tui.py +67 -29
  5. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/chat/widgets.py +61 -1
  6. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/executor.py +35 -6
  7. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli.egg-info/PKG-INFO +2 -2
  8. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli.egg-info/requires.txt +1 -1
  9. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/README.md +0 -0
  10. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/setup.cfg +0 -0
  11. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/chat/__init__.py +0 -0
  12. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/chat/ask.py +0 -0
  13. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/chat/command.py +0 -0
  14. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/chat/history.py +0 -0
  15. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/chat/sse.py +0 -0
  16. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/config.py +0 -0
  17. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/main.py +0 -0
  18. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/runner.py +0 -0
  19. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli/security.py +0 -0
  20. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
  21. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  22. {virtuai_cli-0.6.2 → virtuai_cli-0.7.0}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  23. {virtuai_cli-0.6.2 → virtuai_cli-0.7.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.6.2
3
+ Version: 0.7.0
4
4
  Summary: Run VirtuAI deep agents on your local machine
5
5
  Author-email: uCloudStore <lmoreno@ucloudstore.com>
6
6
  License: Proprietary
@@ -19,7 +19,7 @@ Requires-Dist: certifi>=2024.0
19
19
  Requires-Dist: keyring>=25.0
20
20
  Requires-Dist: typer>=0.12
21
21
  Requires-Dist: rich>=13.0
22
- Requires-Dist: textual>=0.50
22
+ Requires-Dist: textual>=0.86
23
23
 
24
24
  # VirtuAI CLI
25
25
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "virtuai-cli"
7
- version = "0.6.2"
7
+ version = "0.7.0"
8
8
  description = "Run VirtuAI deep agents on your local machine"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -27,7 +27,7 @@ dependencies = [
27
27
  "keyring>=25.0",
28
28
  "typer>=0.12",
29
29
  "rich>=13.0",
30
- "textual>=0.50",
30
+ "textual>=0.86",
31
31
  ]
32
32
 
33
33
  [project.urls]
@@ -1,2 +1,2 @@
1
1
  """VirtuAI local CLI."""
2
- __version__ = "0.6.2"
2
+ __version__ = "0.7.0"
@@ -11,12 +11,12 @@ from textual.app import App, ComposeResult
11
11
  from textual.binding import Binding
12
12
  from textual.containers import Vertical, VerticalScroll
13
13
  from textual.reactive import reactive
14
- from textual.widgets import Footer, Header, Input, Static
14
+ from textual.widgets import Footer, Header, Static, TextArea
15
15
 
16
16
  from virtuai_cli import runner as ws_runner
17
17
  from virtuai_cli.chat.history import list_conversations, load_conversation
18
18
  from virtuai_cli.chat.sse import stream_chat
19
- from virtuai_cli.chat.widgets import AssistantTurn, UserBubble
19
+ from virtuai_cli.chat.widgets import AssistantTurn, ChatInput, TextSegment, UserBubble
20
20
 
21
21
 
22
22
  # Slash command catalog — single source of truth used by /help, the
@@ -25,6 +25,7 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
25
25
  ("/help", "show this list"),
26
26
  ("/clear", "start a fresh conversation"),
27
27
  ("/new", "alias for /clear"),
28
+ ("/copy", "copy the last assistant response to the clipboard"),
28
29
  ("/history", "list this agent's recent conversations"),
29
30
  ("/load", "load a past conversation: /load <session_id>"),
30
31
  ("/models", "list models available for this agent"),
@@ -63,12 +64,6 @@ class ChatApp(App):
63
64
 
64
65
  #slash-hints.-visible { display: block; }
65
66
 
66
- #input {
67
- height: 3;
68
- border: round $primary;
69
- margin: 0 1 1 1;
70
- }
71
-
72
67
  #placeholder {
73
68
  margin: 1 2;
74
69
  color: $text-muted;
@@ -79,6 +74,7 @@ class ChatApp(App):
79
74
  Binding("escape", "cancel_stream", "Cancel", show=True),
80
75
  Binding("ctrl+c", "quit", "Quit", show=True),
81
76
  Binding("ctrl+l", "clear_conversation", "New chat", show=True),
77
+ Binding("ctrl+y", "copy_last", "Copy last", show=True, priority=True),
82
78
  Binding("tab", "complete_slash", "Complete", show=False, priority=True),
83
79
  ]
84
80
 
@@ -119,11 +115,11 @@ class ChatApp(App):
119
115
  yield Static(
120
116
  f"[b]{self.agent_name}[/b] in {self.workspace_name}\n"
121
117
  f"Workdir: {self.workdir}\n"
122
- f"[dim]Type a message and press Enter. Esc cancels. /help for commands.[/dim]",
118
+ f"[dim]Enter sends · Shift+Enter newline · Esc cancels · Ctrl+Y copies · /help for more.[/dim]",
123
119
  id="placeholder",
124
120
  )
125
121
  yield Static("", id="slash-hints")
126
- yield Input(placeholder="Message…", id="input")
122
+ yield ChatInput(id="input")
127
123
  yield Footer()
128
124
 
129
125
  def _initial_status(self) -> str:
@@ -134,7 +130,7 @@ class ChatApp(App):
134
130
  async def on_mount(self) -> None:
135
131
  self.title = "VirtuAI"
136
132
  self.sub_title = f"{self.agent_name} · {self.workspace_name}"
137
- self.query_one(Input).focus()
133
+ self.query_one(ChatInput).focus()
138
134
  self._runner_task = asyncio.create_task(self._run_ws())
139
135
 
140
136
  async def _run_ws(self) -> None:
@@ -190,25 +186,28 @@ class ChatApp(App):
190
186
  hints.update("\n".join(rows))
191
187
  hints.add_class("-visible")
192
188
 
193
- @on(Input.Changed, "#input")
194
- def _on_input_changed(self, event: Input.Changed) -> None:
195
- self._refresh_slash_hints(event.value)
189
+ @on(TextArea.Changed, "#input")
190
+ def _on_input_changed(self, event: TextArea.Changed) -> None:
191
+ # Slash hints only fire while the user is typing the FIRST line
192
+ # — long multi-line messages shouldn't trigger the panel.
193
+ value = event.text_area.text
194
+ first_line = value.split("\n", 1)[0]
195
+ self._refresh_slash_hints(first_line if "\n" not in value else "")
196
196
 
197
197
  def action_complete_slash(self) -> None:
198
198
  """Tab — complete the current slash command from the hint list."""
199
199
  try:
200
- inp = self.query_one("#input", Input)
200
+ inp = self.query_one("#input", ChatInput)
201
201
  except Exception:
202
202
  return
203
- value = inp.value
204
- if not value.startswith("/") or " " in value:
205
- return # not a command-completion context — let Tab default
203
+ value = inp.text
204
+ if "\n" in value or not value.startswith("/") or " " in value:
205
+ return
206
206
  matches = self._matching_commands(value)
207
207
  if not matches:
208
208
  return
209
209
  if len(matches) == 1:
210
- inp.value = matches[0][0] + " "
211
- inp.cursor_position = len(inp.value)
210
+ inp.text = matches[0][0] + " "
212
211
  else:
213
212
  # Multiple matches: extend to common prefix
214
213
  names = [m[0] for m in matches]
@@ -219,20 +218,21 @@ class ChatApp(App):
219
218
  i += 1
220
219
  common = common[:i]
221
220
  if len(common) > len(value):
222
- inp.value = common
223
- inp.cursor_position = len(common)
221
+ inp.text = common
224
222
 
225
223
  # ── Input submission ──────────────────────────────────────────────────
226
- @on(Input.Submitted, "#input")
227
- async def _on_submit(self, event: Input.Submitted) -> None:
228
- text = event.value.strip()
229
- if not text:
224
+ @on(ChatInput.Submitted, "#input")
225
+ async def _on_submit(self, event: ChatInput.Submitted) -> None:
226
+ # Don't strip leading/trailing whitespace blindly — newlines inside
227
+ # the message are intentional. Only reject pure-whitespace inputs.
228
+ text = event.value
229
+ if not text.strip():
230
230
  return
231
- event.input.value = ""
232
231
  self._refresh_slash_hints("") # hide hints after submit
233
232
 
234
- if text.startswith("/"):
235
- await self._handle_slash(text)
233
+ first_line = text.split("\n", 1)[0].strip()
234
+ if first_line.startswith("/") and "\n" not in text:
235
+ await self._handle_slash(first_line)
236
236
  return
237
237
 
238
238
  if self._stream_task and not self._stream_task.done():
@@ -270,17 +270,33 @@ class ChatApp(App):
270
270
  await self._show_history()
271
271
  elif head == "/load":
272
272
  await self._load_session(arg)
273
+ elif head == "/copy":
274
+ self.action_copy_last()
273
275
  elif head == "/help":
274
276
  await self._append(Static(
275
277
  "[b]Commands[/b]\n"
276
278
  " /help this list\n"
277
279
  " /clear, /new start a fresh conversation\n"
280
+ " /copy copy the last assistant response to clipboard\n"
278
281
  " /history list this agent's recent conversations\n"
279
282
  " /load <id> load a past conversation by session_id\n"
280
283
  " /models list models available for this agent\n"
281
284
  " /model <id> switch the model for the next message\n"
282
285
  " /exit, /quit close the TUI\n"
286
+ "\n"
287
+ "[b]Keys[/b]\n"
288
+ " Enter send message\n"
289
+ " Shift/Ctrl+Enter newline inside message\n"
283
290
  " Esc cancel current response\n"
291
+ " Ctrl+L new conversation\n"
292
+ " Ctrl+Y copy last assistant response\n"
293
+ " Tab autocomplete slash command\n"
294
+ "\n"
295
+ "[b]Selecting text[/b]\n"
296
+ " The TUI captures the mouse, so click-drag doesn't select text.\n"
297
+ " Hold [b]Option[/b] (macOS Terminal/iTerm2) or [b]Shift[/b] (most Linux\n"
298
+ " terminals) while dragging to bypass the TUI and use your\n"
299
+ " terminal's native selection + copy."
284
300
  ))
285
301
  else:
286
302
  await self._append(Static(f"[red]Unknown command: {head}[/red] (try /help)"))
@@ -416,6 +432,28 @@ class ChatApp(App):
416
432
  self._stream_task.cancel()
417
433
  self._set_status("response cancelled")
418
434
 
435
+ def action_copy_last(self) -> None:
436
+ """Copy the most recent assistant turn's text to the system clipboard."""
437
+ turns = list(self.query(AssistantTurn))
438
+ if not turns:
439
+ self._set_status("no assistant response yet")
440
+ return
441
+ # Walk newest-first so a /copy right after a streamed reply picks up
442
+ # the response the user is looking at, even if a turn is empty.
443
+ for turn in reversed(turns):
444
+ segments = list(turn.query(TextSegment))
445
+ text = "\n\n".join(s.content for s in segments if s.content).strip()
446
+ if text:
447
+ try:
448
+ self.copy_to_clipboard(text)
449
+ except Exception as exc:
450
+ self._set_status(f"clipboard error: {exc}")
451
+ return
452
+ preview = text[:60].replace("\n", " ")
453
+ self._set_status(f"copied: {preview}{'…' if len(text) > 60 else ''}")
454
+ return
455
+ self._set_status("no text to copy in the last response")
456
+
419
457
  def _follow_bottom(self) -> None:
420
458
  """Scroll to the end of the conversation.
421
459
 
@@ -15,8 +15,68 @@ from typing import Any, Optional
15
15
 
16
16
  from rich.markdown import Markdown
17
17
  from rich.text import Text
18
+ from textual.binding import Binding
18
19
  from textual.containers import Vertical
19
- from textual.widgets import Static
20
+ from textual.message import Message
21
+ from textual.widgets import Static, TextArea
22
+
23
+
24
+ class ChatInput(TextArea):
25
+ """Multi-line input — Enter submits, Shift/Ctrl+Enter inserts a newline.
26
+
27
+ Height grows with content up to a cap so a long paste doesn't take over
28
+ the whole screen; once the cap is hit the widget scrolls internally.
29
+ """
30
+
31
+ DEFAULT_CSS = """
32
+ ChatInput {
33
+ height: auto;
34
+ min-height: 3;
35
+ max-height: 14;
36
+ border: round $primary;
37
+ margin: 0 1 1 1;
38
+ padding: 0 1;
39
+ }
40
+ """
41
+
42
+ BINDINGS = [
43
+ Binding("enter", "submit", "Submit", show=False, priority=True),
44
+ Binding("shift+enter", "insert_newline", "Newline", show=False, priority=True),
45
+ Binding("ctrl+enter", "insert_newline", "Newline", show=False, priority=True),
46
+ Binding("ctrl+j", "insert_newline", "Newline", show=False, priority=True),
47
+ ]
48
+
49
+ class Submitted(Message):
50
+ """Posted when the user hits Enter on a non-empty buffer."""
51
+ def __init__(self, input_widget: "ChatInput", value: str) -> None:
52
+ self.input = input_widget
53
+ self.value = value
54
+ super().__init__()
55
+
56
+ @property
57
+ def control(self) -> "ChatInput":
58
+ # Lets `@on(ChatInput.Submitted, "#input")` selectors work.
59
+ return self.input
60
+
61
+ def __init__(self, **kwargs: Any) -> None:
62
+ super().__init__(**kwargs)
63
+ self.show_line_numbers = False
64
+ # We rely on Textual's soft-wrap so long single lines wrap visually
65
+ # instead of overflowing the input box.
66
+ try:
67
+ self.soft_wrap = True # type: ignore[attr-defined]
68
+ except Exception:
69
+ pass
70
+
71
+ def action_submit(self) -> None:
72
+ text = self.text
73
+ if not text.strip():
74
+ return
75
+ self.text = ""
76
+ self.post_message(self.Submitted(self, text))
77
+
78
+ def action_insert_newline(self) -> None:
79
+ self.insert("\n")
20
80
 
21
81
 
22
82
  # Server-side displays that should render as an EditPreviewCard (path + diff)
@@ -11,6 +11,37 @@ from typing import Optional
11
11
  from virtuai_cli.security import check_command, jail_path, scrub_env
12
12
 
13
13
  _EXECUTE_TIMEOUT = 300 # seconds
14
+ # Hard cap on the combined stdout+stderr we ship back over the WebSocket.
15
+ # Anything larger gets head/tail-clipped with a marker so a runaway command
16
+ # (e.g. `cat /dev/urandom`, `find /`) can't OOM the runner or blow past the
17
+ # server-side frame-size limit and kill the connection.
18
+ _MAX_OUTPUT_BYTES = 2_000_000 # ~2 MB
19
+
20
+
21
+ def _clip_output(stdout: bytes, stderr: bytes) -> str:
22
+ """Decode + concatenate stdout/stderr, head/tail-clipping if oversized."""
23
+ total = len(stdout) + len(stderr)
24
+ if total <= _MAX_OUTPUT_BYTES:
25
+ parts = []
26
+ if stdout:
27
+ parts.append(stdout.decode(errors="replace"))
28
+ if stderr:
29
+ parts.append(stderr.decode(errors="replace"))
30
+ return "\n".join(parts)
31
+
32
+ # Keep half the budget at the start and half at the end of the combined
33
+ # text — that's where users usually look for context vs error tails.
34
+ combined = (stdout + (b"\n" if stdout and stderr else b"") + stderr)
35
+ keep = _MAX_OUTPUT_BYTES // 2
36
+ head = combined[:keep].decode(errors="replace")
37
+ tail = combined[-keep:].decode(errors="replace")
38
+ dropped = len(combined) - 2 * keep
39
+ return (
40
+ f"{head}\n"
41
+ f"\n... [output truncated: {dropped:,} bytes dropped, "
42
+ f"{len(combined):,} bytes total] ...\n\n"
43
+ f"{tail}"
44
+ )
14
45
 
15
46
 
16
47
  def _jail_wrap(command: str, workdir: Path) -> str:
@@ -49,12 +80,10 @@ def execute(command: str, workdir: Path, timeout: int = _EXECUTE_TIMEOUT, extra_
49
80
  capture_output=True,
50
81
  timeout=timeout,
51
82
  )
52
- parts = []
53
- if result.stdout:
54
- parts.append(result.stdout.decode(errors="replace"))
55
- if result.stderr:
56
- parts.append(result.stderr.decode(errors="replace"))
57
- return {"output": "\n".join(parts), "exit_code": result.returncode}
83
+ return {
84
+ "output": _clip_output(result.stdout or b"", result.stderr or b""),
85
+ "exit_code": result.returncode,
86
+ }
58
87
  except subprocess.TimeoutExpired:
59
88
  return {"output": f"command timed out after {timeout}s", "exit_code": 124}
60
89
  except Exception as exc:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 0.6.2
3
+ Version: 0.7.0
4
4
  Summary: Run VirtuAI deep agents on your local machine
5
5
  Author-email: uCloudStore <lmoreno@ucloudstore.com>
6
6
  License: Proprietary
@@ -19,7 +19,7 @@ Requires-Dist: certifi>=2024.0
19
19
  Requires-Dist: keyring>=25.0
20
20
  Requires-Dist: typer>=0.12
21
21
  Requires-Dist: rich>=13.0
22
- Requires-Dist: textual>=0.50
22
+ Requires-Dist: textual>=0.86
23
23
 
24
24
  # VirtuAI CLI
25
25
 
@@ -5,4 +5,4 @@ certifi>=2024.0
5
5
  keyring>=25.0
6
6
  typer>=0.12
7
7
  rich>=13.0
8
- textual>=0.50
8
+ textual>=0.86
File without changes
File without changes