virtuai-cli 1.0.6__tar.gz → 1.0.7__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-1.0.6 → virtuai_cli-1.0.7}/PKG-INFO +18 -10
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/README.md +16 -8
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/pyproject.toml +5 -4
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/__init__.py +1 -1
- virtuai_cli-1.0.7/src/virtuai_cli/chat/clipboard.py +123 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/command.py +2 -2
- virtuai_cli-1.0.7/src/virtuai_cli/chat/devtools.py +214 -0
- virtuai_cli-1.0.7/src/virtuai_cli/chat/highlight.py +87 -0
- virtuai_cli-1.0.7/src/virtuai_cli/chat/journal.py +130 -0
- virtuai_cli-1.0.7/src/virtuai_cli/chat/look.py +469 -0
- virtuai_cli-1.0.7/src/virtuai_cli/chat/panels.py +259 -0
- virtuai_cli-1.0.7/src/virtuai_cli/chat/review.py +166 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/sse.py +6 -0
- virtuai_cli-1.0.7/src/virtuai_cli/chat/steps.py +516 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/tui.py +1098 -77
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/widgets.py +342 -27
- virtuai_cli-1.0.7/src/virtuai_cli/command_guard.py +279 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/executor.py +22 -0
- virtuai_cli-1.0.7/src/virtuai_cli/guard_setup.py +284 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/main.py +46 -2
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/runner.py +21 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/security.py +3 -1
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli.egg-info/PKG-INFO +18 -10
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli.egg-info/SOURCES.txt +12 -0
- virtuai_cli-1.0.7/tests/test_command_guard.py +143 -0
- virtuai_cli-1.0.7/tests/test_guard_setup.py +101 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/setup.cfg +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/browser.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/agents.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/ask.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/history.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/ollama_state.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/omlx_state.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/opencode.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/pull.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli.egg-info/requires.txt +1 -1
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli.egg-info/top_level.txt +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_browser.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_frame_concurrency.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_ollama_options.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_ollama_state.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_omlx.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_process_group_kill.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_proxy_env.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_pull_fallback.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_run_single_instance.py +0 -0
- {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_session_isolation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: virtuai-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.7
|
|
4
4
|
Summary: Run VirtuAI deep agents on your local machine
|
|
5
5
|
Author-email: uCloudStore <lmoreno@ucloudstore.com>
|
|
6
6
|
License: Proprietary
|
|
@@ -20,9 +20,9 @@ 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
|
+
Requires-Dist: llmfit>=1.1.16
|
|
23
24
|
Provides-Extra: local
|
|
24
25
|
Requires-Dist: langchain-ollama>=0.3.4; extra == "local"
|
|
25
|
-
Requires-Dist: llmfit>=1.1.16; extra == "local"
|
|
26
26
|
Requires-Dist: langchain-openai>=0.3; extra == "local"
|
|
27
27
|
Provides-Extra: browser
|
|
28
28
|
Requires-Dist: browser-use>=0.1.40; extra == "browser"
|
|
@@ -63,6 +63,18 @@ virtuai pair <CODE>
|
|
|
63
63
|
|
|
64
64
|
The pairing is one-time; the resulting token is stored in your system keychain.
|
|
65
65
|
|
|
66
|
+
Pairing then sets up the **command check** (also on the first `virtuai chat`, or any time with `virtuai guard setup`). Before the agent runs a command on this machine, a small local model — `tev1:0.8b` (811 MB) on [Ollama](https://ollama.com) ≥ 0.35 — says what the command does:
|
|
67
|
+
|
|
68
|
+
- reads, or runs the project's tests/build → it runs;
|
|
69
|
+
- deletes, installs, writes, uses the network, reads secrets, needs root → you are shown the command and decide. If you don't authorize it, it is left in the input as `!command` for you to run yourself; its output goes back to the agent.
|
|
70
|
+
|
|
71
|
+
Commands that only read (`ls`, `cat`, `git status`, `rg`…) run without the model; inline code (`python -c`, `bash -c`, `| sh`), `eval` and `sudo` are always asked. Setup uses [llmfit](https://pypi.org/project/llmfit/) to decide whether the model fits this machine, then offers to install or start Ollama and download the model. **If it does not fit, or you decline, there is no command check: what the agent runs is your responsibility**, you are asked to accept that, and the chat shows it.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
virtuai guard status # on, rules only, or off
|
|
75
|
+
virtuai guard test "rm -rf dist" # what it would do: run or ask
|
|
76
|
+
```
|
|
77
|
+
|
|
66
78
|
### 2. Chat with an agent
|
|
67
79
|
|
|
68
80
|
```bash
|
|
@@ -71,13 +83,9 @@ virtuai chat
|
|
|
71
83
|
|
|
72
84
|
Opens an interactive TUI. Pick an agent (skipped if your workspace has only one), type a message, watch the response stream in. The agent's tool calls — `bash`, file reads, file writes — execute on this machine, in the current directory.
|
|
73
85
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
```
|
|
77
|
-
/plan
|
|
78
|
-
```
|
|
86
|
+
**Plan mode** (`Shift+Tab` or `/plan`): the same agent investigates and proposes a plan without changing anything — it may read and run commands that only look; writes, edits and tools that change something are refused by the server. When the plan is ready, choose to carry it out (plan mode turns off) or keep planning.
|
|
79
87
|
|
|
80
|
-
|
|
88
|
+
Every workspace also includes a built-in **Plan** agent, a separate agent with its own conversation: `/agent Plan` or `virtuai chat --agent Plan`. List agents with `/agents`.
|
|
81
89
|
|
|
82
90
|
### 3. Use it from a shell script
|
|
83
91
|
|
|
@@ -97,7 +105,7 @@ Agents can run inference on **your own machine** — with [Ollama](https://ollam
|
|
|
97
105
|
|
|
98
106
|
### Set up
|
|
99
107
|
|
|
100
|
-
1. Install the CLI with the local extra (pulls `langchain-ollama
|
|
108
|
+
1. Install the CLI with the local extra (pulls `langchain-ollama` and `langchain-openai` for oMLX; `llmfit` comes with the base install):
|
|
101
109
|
```bash
|
|
102
110
|
pip install 'virtuai-cli[local]'
|
|
103
111
|
```
|
|
@@ -136,7 +144,7 @@ Each model call goes to the runner of the person the turn is for; if they have n
|
|
|
136
144
|
|
|
137
145
|
### What the runner tells the server
|
|
138
146
|
|
|
139
|
-
At connect and then every minute: the models it has, the Ollama version, what is loaded and how much memory it really took. With `llmfit`
|
|
147
|
+
At connect and then every minute: the models it has, the Ollama version, what is loaded and how much memory it really took. With `llmfit` (part of the base install) it also estimates which pulled models fit this machine, so the server does not send a model to a runner that cannot hold it until it has actually run there. A model that spills to the CPU gets half the context on its next call on that machine (down to 4k).
|
|
140
148
|
|
|
141
149
|
The context sent to Ollama is 16k by default (set on the server). A model that does not fit at that context — `qwen3:14b` on a 16 GB Mac spills 7% to the CPU — is capped at half on that machine after its first call, and the server is told in the same reply: from then on it sends prompts that fit, compacting the conversation when they would not, instead of letting Ollama cut the start of the prompt. `OLLAMA_NUM_CTX` sets a lower context on a machine with little memory. For qwen3 the runner turns Ollama's thinking mode off: left on, it reasoned for minutes before every tool call.
|
|
142
150
|
|
|
@@ -34,6 +34,18 @@ virtuai pair <CODE>
|
|
|
34
34
|
|
|
35
35
|
The pairing is one-time; the resulting token is stored in your system keychain.
|
|
36
36
|
|
|
37
|
+
Pairing then sets up the **command check** (also on the first `virtuai chat`, or any time with `virtuai guard setup`). Before the agent runs a command on this machine, a small local model — `tev1:0.8b` (811 MB) on [Ollama](https://ollama.com) ≥ 0.35 — says what the command does:
|
|
38
|
+
|
|
39
|
+
- reads, or runs the project's tests/build → it runs;
|
|
40
|
+
- deletes, installs, writes, uses the network, reads secrets, needs root → you are shown the command and decide. If you don't authorize it, it is left in the input as `!command` for you to run yourself; its output goes back to the agent.
|
|
41
|
+
|
|
42
|
+
Commands that only read (`ls`, `cat`, `git status`, `rg`…) run without the model; inline code (`python -c`, `bash -c`, `| sh`), `eval` and `sudo` are always asked. Setup uses [llmfit](https://pypi.org/project/llmfit/) to decide whether the model fits this machine, then offers to install or start Ollama and download the model. **If it does not fit, or you decline, there is no command check: what the agent runs is your responsibility**, you are asked to accept that, and the chat shows it.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
virtuai guard status # on, rules only, or off
|
|
46
|
+
virtuai guard test "rm -rf dist" # what it would do: run or ask
|
|
47
|
+
```
|
|
48
|
+
|
|
37
49
|
### 2. Chat with an agent
|
|
38
50
|
|
|
39
51
|
```bash
|
|
@@ -42,13 +54,9 @@ virtuai chat
|
|
|
42
54
|
|
|
43
55
|
Opens an interactive TUI. Pick an agent (skipped if your workspace has only one), type a message, watch the response stream in. The agent's tool calls — `bash`, file reads, file writes — execute on this machine, in the current directory.
|
|
44
56
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
```
|
|
48
|
-
/plan
|
|
49
|
-
```
|
|
57
|
+
**Plan mode** (`Shift+Tab` or `/plan`): the same agent investigates and proposes a plan without changing anything — it may read and run commands that only look; writes, edits and tools that change something are refused by the server. When the plan is ready, choose to carry it out (plan mode turns off) or keep planning.
|
|
50
58
|
|
|
51
|
-
|
|
59
|
+
Every workspace also includes a built-in **Plan** agent, a separate agent with its own conversation: `/agent Plan` or `virtuai chat --agent Plan`. List agents with `/agents`.
|
|
52
60
|
|
|
53
61
|
### 3. Use it from a shell script
|
|
54
62
|
|
|
@@ -68,7 +76,7 @@ Agents can run inference on **your own machine** — with [Ollama](https://ollam
|
|
|
68
76
|
|
|
69
77
|
### Set up
|
|
70
78
|
|
|
71
|
-
1. Install the CLI with the local extra (pulls `langchain-ollama
|
|
79
|
+
1. Install the CLI with the local extra (pulls `langchain-ollama` and `langchain-openai` for oMLX; `llmfit` comes with the base install):
|
|
72
80
|
```bash
|
|
73
81
|
pip install 'virtuai-cli[local]'
|
|
74
82
|
```
|
|
@@ -107,7 +115,7 @@ Each model call goes to the runner of the person the turn is for; if they have n
|
|
|
107
115
|
|
|
108
116
|
### What the runner tells the server
|
|
109
117
|
|
|
110
|
-
At connect and then every minute: the models it has, the Ollama version, what is loaded and how much memory it really took. With `llmfit`
|
|
118
|
+
At connect and then every minute: the models it has, the Ollama version, what is loaded and how much memory it really took. With `llmfit` (part of the base install) it also estimates which pulled models fit this machine, so the server does not send a model to a runner that cannot hold it until it has actually run there. A model that spills to the CPU gets half the context on its next call on that machine (down to 4k).
|
|
111
119
|
|
|
112
120
|
The context sent to Ollama is 16k by default (set on the server). A model that does not fit at that context — `qwen3:14b` on a 16 GB Mac spills 7% to the CPU — is capped at half on that machine after its first call, and the server is told in the same reply: from then on it sends prompts that fit, compacting the conversation when they would not, instead of letting Ollama cut the start of the prompt. `OLLAMA_NUM_CTX` sets a lower context on a machine with little memory. For qwen3 the runner turns Ollama's thinking mode off: left on, it reasoned for minutes before every tool call.
|
|
113
121
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "virtuai-cli"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.7"
|
|
8
8
|
description = "Run VirtuAI deep agents on your local machine"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -28,6 +28,10 @@ dependencies = [
|
|
|
28
28
|
"typer>=0.12",
|
|
29
29
|
"rich>=13.0",
|
|
30
30
|
"textual>=0.86",
|
|
31
|
+
# Whether the command classifier (tev1:0.8b) fits this machine, decided at
|
|
32
|
+
# setup (virtuai_cli/guard_setup.py); also which pulled models fit, for the
|
|
33
|
+
# server (ollama_state.py). Ships its own binary in the wheel.
|
|
34
|
+
"llmfit>=1.1.16",
|
|
31
35
|
]
|
|
32
36
|
|
|
33
37
|
# Optional: local model inference. Pulls langchain-ollama (+ langchain-core) so
|
|
@@ -36,9 +40,6 @@ dependencies = [
|
|
|
36
40
|
[project.optional-dependencies]
|
|
37
41
|
local = [
|
|
38
42
|
"langchain-ollama>=0.3.4", # `reasoning` (think on/off)
|
|
39
|
-
# Which pulled models fit this machine, before they are first loaded
|
|
40
|
-
# (virtuai_cli/ollama_state.py). Ships its own binary in the wheel.
|
|
41
|
-
"llmfit>=1.1.16",
|
|
42
43
|
# oMLX (MLX on Apple Silicon) speaks the OpenAI API (virtuai_cli/omlx_state.py).
|
|
43
44
|
"langchain-openai>=0.3",
|
|
44
45
|
]
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""VirtuAI local CLI."""
|
|
2
|
-
__version__ = "1.0.
|
|
2
|
+
__version__ = "1.0.7"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""The system clipboard, for the chat TUI: copy what is selected, paste
|
|
2
|
+
files and images as attachments.
|
|
3
|
+
|
|
4
|
+
Textual copies with OSC 52, which Terminal.app ignores and iTerm2 only
|
|
5
|
+
honours when allowed; the native tools (pbcopy/pbpaste and osascript on
|
|
6
|
+
macOS, wl-copy/wl-paste or xclip on Linux) work everywhere they exist, so
|
|
7
|
+
they go first and OSC 52 is the fallback.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import shlex
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
_MAC = sys.platform == "darwin"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _run(cmd: list[str], data: Optional[bytes] = None, timeout: float = 3) -> Optional[bytes]:
|
|
26
|
+
if not shutil.which(cmd[0]):
|
|
27
|
+
return None
|
|
28
|
+
try:
|
|
29
|
+
p = subprocess.run(cmd, input=data, capture_output=True, timeout=timeout)
|
|
30
|
+
except (OSError, subprocess.SubprocessError):
|
|
31
|
+
return None
|
|
32
|
+
return p.stdout if p.returncode == 0 else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def copy_text(app: Any, text: str) -> bool:
|
|
36
|
+
"""Put `text` on the clipboard. True when a native tool took it."""
|
|
37
|
+
data = text.encode("utf-8")
|
|
38
|
+
for cmd in (["pbcopy"],) if _MAC else (["wl-copy"], ["xclip", "-selection", "clipboard"]):
|
|
39
|
+
if _run(cmd, data) is not None:
|
|
40
|
+
return True
|
|
41
|
+
try:
|
|
42
|
+
app.copy_to_clipboard(text) # OSC 52
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_text() -> str:
|
|
49
|
+
for cmd in (["pbpaste"],) if _MAC else (["wl-paste", "--no-newline"], ["xclip", "-selection", "clipboard", "-o"]):
|
|
50
|
+
out = _run(cmd)
|
|
51
|
+
if out is not None:
|
|
52
|
+
return out.decode("utf-8", "replace")
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _paste_dir() -> Path:
|
|
57
|
+
d = Path(tempfile.gettempdir()) / "virtuai-paste"
|
|
58
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
return d
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_image() -> Optional[Path]:
|
|
63
|
+
"""An image on the clipboard (a screenshot copied, an image copied from a
|
|
64
|
+
browser), saved as PNG; None when there is none."""
|
|
65
|
+
dest = _paste_dir() / f"pasted-{time.strftime('%Y%m%d-%H%M%S')}.png"
|
|
66
|
+
if _MAC:
|
|
67
|
+
script = (
|
|
68
|
+
f'set f to POSIX file "{dest}"\n'
|
|
69
|
+
'try\n set d to the clipboard as «class PNGf»\non error\n return "none"\nend try\n'
|
|
70
|
+
"set fh to open for access f with write permission\n"
|
|
71
|
+
"write d to fh\nclose access fh\nreturn \"ok\""
|
|
72
|
+
)
|
|
73
|
+
out = _run(["osascript", "-e", script])
|
|
74
|
+
return dest if out is not None and out.strip() == b"ok" and dest.exists() else None
|
|
75
|
+
for cmd in (["wl-paste", "--type", "image/png"], ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"]):
|
|
76
|
+
out = _run(cmd)
|
|
77
|
+
if out and out[:8] == b"\x89PNG\r\n\x1a\n":
|
|
78
|
+
dest.write_bytes(out)
|
|
79
|
+
return dest
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_copied_files() -> list[Path]:
|
|
84
|
+
"""Files copied in Finder (macOS): their paths."""
|
|
85
|
+
if not _MAC:
|
|
86
|
+
return []
|
|
87
|
+
script = (
|
|
88
|
+
"try\n set fs to the clipboard as «class furl»\non error\n return \"\"\nend try\n"
|
|
89
|
+
"return POSIX path of fs"
|
|
90
|
+
)
|
|
91
|
+
out = _run(["osascript", "-e", script])
|
|
92
|
+
if not out:
|
|
93
|
+
return []
|
|
94
|
+
p = Path(out.decode("utf-8", "replace").strip())
|
|
95
|
+
return [p] if p.is_file() else []
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_URI = re.compile(r"^file://")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def paths_in(text: str) -> list[Path]:
|
|
102
|
+
"""Existing files named by pasted text — a path dragged into the terminal
|
|
103
|
+
(quoted or with escaped spaces), several of them, or file:// URIs. Empty
|
|
104
|
+
when the text is anything else, so ordinary text pastes as text."""
|
|
105
|
+
text = (text or "").strip()
|
|
106
|
+
if not text or len(text) > 4096:
|
|
107
|
+
return []
|
|
108
|
+
try:
|
|
109
|
+
parts = shlex.split(text)
|
|
110
|
+
except ValueError:
|
|
111
|
+
parts = text.splitlines()
|
|
112
|
+
out = []
|
|
113
|
+
for part in parts:
|
|
114
|
+
from urllib.parse import unquote
|
|
115
|
+
|
|
116
|
+
part = unquote(_URI.sub("", part.strip()))
|
|
117
|
+
if not part:
|
|
118
|
+
continue
|
|
119
|
+
p = Path(part).expanduser()
|
|
120
|
+
if not p.is_file():
|
|
121
|
+
return []
|
|
122
|
+
out.append(p)
|
|
123
|
+
return out
|
|
@@ -49,6 +49,7 @@ def run_chat(
|
|
|
49
49
|
workdir: Optional[Path],
|
|
50
50
|
server: Optional[str],
|
|
51
51
|
agent: Optional[str],
|
|
52
|
+
continue_session: bool = False,
|
|
52
53
|
) -> None:
|
|
53
54
|
"""Launch the chat TUI. Called by the `chat` command in main.py."""
|
|
54
55
|
server_url = (server or cfg.get_server_url()).rstrip("/")
|
|
@@ -77,7 +78,6 @@ def run_chat(
|
|
|
77
78
|
workspace_name=info["workspace_name"],
|
|
78
79
|
default_model_id=picked.get("default_model_id") or None,
|
|
79
80
|
allowed_models=picked.get("allowed_models") or [],
|
|
81
|
+
continue_session=continue_session,
|
|
80
82
|
)
|
|
81
|
-
saved_theme = cfg.get("theme", "textual-dark")
|
|
82
|
-
app.theme = saved_theme
|
|
83
83
|
app.run()
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""The chat TUI as a programming tool: git, the shell, your editor, @files.
|
|
2
|
+
|
|
3
|
+
- `git_status`: branch, ahead/behind and changed files, for the top bar.
|
|
4
|
+
- `run_shell`: `!cmd` runs here, in the workdir, its output streamed.
|
|
5
|
+
- `open_in_editor`: a file at a line in $VISUAL/$EDITOR (the TUI suspends
|
|
6
|
+
for a terminal editor), VS Code or Cursor, or the system's default app.
|
|
7
|
+
- `notify`: a desktop notification when a long answer ends while you are
|
|
8
|
+
elsewhere.
|
|
9
|
+
- `file_index` / `fuzzy`: the workdir's files for `@` mentions — git's own
|
|
10
|
+
list when it is a repository, else a walk that skips the usual noise.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import os
|
|
17
|
+
import shlex
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Awaitable, Callable, Optional
|
|
24
|
+
|
|
25
|
+
SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache",
|
|
26
|
+
"dist", "build", ".next", ".turbo", "target", ".idea", ".tox", ".cache"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── git ──────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class GitState:
|
|
33
|
+
branch: str
|
|
34
|
+
ahead: int = 0
|
|
35
|
+
behind: int = 0
|
|
36
|
+
changed: int = 0
|
|
37
|
+
untracked: int = 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _git(workdir: Path, *args: str, timeout: float = 5) -> Optional[str]:
|
|
41
|
+
if not shutil.which("git"):
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
proc = await asyncio.create_subprocess_exec(
|
|
45
|
+
"git", *args, cwd=str(workdir), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
|
46
|
+
out, _ = await asyncio.wait_for(proc.communicate(), timeout)
|
|
47
|
+
except (OSError, asyncio.TimeoutError):
|
|
48
|
+
return None
|
|
49
|
+
return out.decode("utf-8", "replace") if proc.returncode == 0 else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def git_status(workdir: Path) -> Optional[GitState]:
|
|
53
|
+
"""None when the workdir is not in a git repository."""
|
|
54
|
+
out = await _git(workdir, "status", "--porcelain=v1", "--branch")
|
|
55
|
+
if out is None:
|
|
56
|
+
return None
|
|
57
|
+
lines = out.splitlines()
|
|
58
|
+
head = lines[0][3:] if lines and lines[0].startswith("## ") else ""
|
|
59
|
+
branch = head.split("...")[0].replace("No commits yet on ", "") or "?"
|
|
60
|
+
state = GitState(branch=branch)
|
|
61
|
+
if "[" in head:
|
|
62
|
+
info = head[head.index("[") + 1: head.rindex("]")]
|
|
63
|
+
for part in info.split(","):
|
|
64
|
+
part = part.strip()
|
|
65
|
+
if part.startswith("ahead "):
|
|
66
|
+
state.ahead = int(part[6:])
|
|
67
|
+
elif part.startswith("behind "):
|
|
68
|
+
state.behind = int(part[7:])
|
|
69
|
+
for line in lines[1:]:
|
|
70
|
+
if line.startswith("??"):
|
|
71
|
+
state.untracked += 1
|
|
72
|
+
elif line.strip():
|
|
73
|
+
state.changed += 1
|
|
74
|
+
return state
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def git_diff(workdir: Path) -> str:
|
|
78
|
+
stat = await _git(workdir, "diff", "--stat", "HEAD") or ""
|
|
79
|
+
body = await _git(workdir, "diff", "HEAD", timeout=10) or ""
|
|
80
|
+
untracked = await _git(workdir, "ls-files", "--others", "--exclude-standard") or ""
|
|
81
|
+
extra = "".join(f"?? {p}\n" for p in untracked.splitlines()[:50])
|
|
82
|
+
return (stat + ("\n" + extra if extra else "") + "\n" + body).strip()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── shell ────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
async def run_shell(cmd: str, workdir: Path, on_output: Callable[[str], Awaitable[None]],
|
|
88
|
+
timeout: float = 600) -> tuple[int, str]:
|
|
89
|
+
"""Run `cmd` in the user's shell, in the workdir; stream its output. (code, output)"""
|
|
90
|
+
shell = os.environ.get("SHELL") or "/bin/sh"
|
|
91
|
+
proc = await asyncio.create_subprocess_exec(
|
|
92
|
+
shell, "-lc", cmd, cwd=str(workdir),
|
|
93
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
|
94
|
+
env={**os.environ, "TERM": "dumb", "NO_COLOR": "1"})
|
|
95
|
+
chunks: list[str] = []
|
|
96
|
+
|
|
97
|
+
async def pump() -> None:
|
|
98
|
+
assert proc.stdout is not None
|
|
99
|
+
while True:
|
|
100
|
+
data = await proc.stdout.read(4096)
|
|
101
|
+
if not data:
|
|
102
|
+
break
|
|
103
|
+
text = data.decode("utf-8", "replace")
|
|
104
|
+
chunks.append(text)
|
|
105
|
+
await on_output(text)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
await asyncio.wait_for(pump(), timeout)
|
|
109
|
+
code = await proc.wait()
|
|
110
|
+
except asyncio.TimeoutError:
|
|
111
|
+
proc.kill()
|
|
112
|
+
code = 124
|
|
113
|
+
except asyncio.CancelledError:
|
|
114
|
+
proc.kill()
|
|
115
|
+
raise
|
|
116
|
+
return code, "".join(chunks)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ── editor ───────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
_GUI = {"code": "-g", "cursor": "-g", "code-insiders": "-g", "subl": None, "zed": None}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def open_in_editor(app: Any, path: Path, line: Optional[int] = None) -> str:
|
|
125
|
+
"""Open `path` at `line`. Returns how it was opened."""
|
|
126
|
+
target = f"{path}:{line}" if line else str(path)
|
|
127
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ""
|
|
128
|
+
if editor:
|
|
129
|
+
parts = shlex.split(editor)
|
|
130
|
+
name = Path(parts[0]).name
|
|
131
|
+
if name in _GUI:
|
|
132
|
+
flag = _GUI[name]
|
|
133
|
+
subprocess.Popen(parts + ([flag, target] if flag else [target]),
|
|
134
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
135
|
+
return name
|
|
136
|
+
args = parts + ([f"+{line}"] if line and name in ("vim", "nvim", "vi", "nano", "emacs", "hx", "micro") else []) + [str(path)]
|
|
137
|
+
with app.suspend():
|
|
138
|
+
subprocess.call(args)
|
|
139
|
+
return name
|
|
140
|
+
for gui in ("code", "cursor"):
|
|
141
|
+
if shutil.which(gui):
|
|
142
|
+
subprocess.Popen([gui, "-g", target], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
143
|
+
return gui
|
|
144
|
+
opener = "open" if sys.platform == "darwin" else "xdg-open"
|
|
145
|
+
if shutil.which(opener):
|
|
146
|
+
subprocess.Popen([opener, str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
147
|
+
return opener
|
|
148
|
+
return ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ── notifications ────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
def notify(title: str, body: str) -> None:
|
|
154
|
+
try:
|
|
155
|
+
if sys.platform == "darwin" and shutil.which("osascript"):
|
|
156
|
+
esc = lambda s: s.replace("\\", "\\\\").replace('"', '\\"') # noqa: E731
|
|
157
|
+
subprocess.Popen(["osascript", "-e", f'display notification "{esc(body)}" with title "{esc(title)}"'],
|
|
158
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
159
|
+
elif shutil.which("notify-send"):
|
|
160
|
+
subprocess.Popen(["notify-send", title, body], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
161
|
+
except OSError:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── @files ───────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
def file_index(workdir: Path, limit: int = 20000) -> list[str]:
|
|
168
|
+
"""The workdir's files, relative: git's list in a repository, else a walk."""
|
|
169
|
+
if shutil.which("git"):
|
|
170
|
+
try:
|
|
171
|
+
out = subprocess.run(["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
|
172
|
+
cwd=str(workdir), capture_output=True, timeout=5)
|
|
173
|
+
if out.returncode == 0:
|
|
174
|
+
return out.stdout.decode("utf-8", "replace").splitlines()[:limit]
|
|
175
|
+
except (OSError, subprocess.SubprocessError):
|
|
176
|
+
pass
|
|
177
|
+
found: list[str] = []
|
|
178
|
+
for root, dirs, files in os.walk(workdir):
|
|
179
|
+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
|
|
180
|
+
for name in files:
|
|
181
|
+
found.append(os.path.relpath(os.path.join(root, name), workdir))
|
|
182
|
+
if len(found) >= limit:
|
|
183
|
+
return found
|
|
184
|
+
return found
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def fuzzy(query: str, items: list[str], limit: int = 12) -> list[str]:
|
|
188
|
+
"""Items whose letters follow the query's in order, best first: a match in
|
|
189
|
+
the file name beats one spread over the path, a tight one a loose one."""
|
|
190
|
+
q = query.lower()
|
|
191
|
+
if not q:
|
|
192
|
+
return items[:limit]
|
|
193
|
+
scored = []
|
|
194
|
+
for item in items:
|
|
195
|
+
low = item.lower()
|
|
196
|
+
name = low.rsplit("/", 1)[-1]
|
|
197
|
+
if q in name:
|
|
198
|
+
scored.append((0, name.index(q), len(item), item))
|
|
199
|
+
continue
|
|
200
|
+
if q in low:
|
|
201
|
+
scored.append((1, low.index(q), len(item), item))
|
|
202
|
+
continue
|
|
203
|
+
pos, gaps, ok = -1, 0, True
|
|
204
|
+
for ch in q:
|
|
205
|
+
nxt = low.find(ch, pos + 1)
|
|
206
|
+
if nxt < 0:
|
|
207
|
+
ok = False
|
|
208
|
+
break
|
|
209
|
+
gaps += nxt - pos - 1
|
|
210
|
+
pos = nxt
|
|
211
|
+
if ok:
|
|
212
|
+
scored.append((2, gaps, len(item), item))
|
|
213
|
+
scored.sort()
|
|
214
|
+
return [s[3] for s in scored[:limit]]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Code in the theme's colors, by language — web chat's code blocks in cells.
|
|
2
|
+
|
|
3
|
+
One place for the colors of code: a file's diff (EditPreviewCard), markdown's
|
|
4
|
+
code blocks (TextSegment) and the file viewer (panels.FilePreview). Dark
|
|
5
|
+
themes use One Dark, light ones Pygments' default; diff lines keep their
|
|
6
|
+
syntax colors over a green or red tint instead of turning all green.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from rich.style import Style
|
|
15
|
+
from rich.syntax import Syntax
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
DARK, LIGHT = "one-dark", "default"
|
|
19
|
+
|
|
20
|
+
#: Diff tints: added and removed lines, dark and light.
|
|
21
|
+
TINT = {
|
|
22
|
+
(True, "+"): "rgb(22,48,30)", (True, "-"): "rgb(60,24,26)",
|
|
23
|
+
(False, "+"): "rgb(230,255,236)", (False, "-"): "rgb(255,235,233)",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_dark(app: Any) -> bool:
|
|
28
|
+
theme = getattr(app, "current_theme", None)
|
|
29
|
+
return bool(getattr(theme, "dark", True))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def code_theme(app: Any) -> str:
|
|
33
|
+
return DARK if is_dark(app) else LIGHT
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@lru_cache(maxsize=256)
|
|
37
|
+
def _lexer(path: str, sample: str):
|
|
38
|
+
from pygments.lexers import guess_lexer_for_filename, get_lexer_for_filename
|
|
39
|
+
from pygments.util import ClassNotFound
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
return get_lexer_for_filename(path, stripnl=False)
|
|
43
|
+
except ClassNotFound:
|
|
44
|
+
try:
|
|
45
|
+
return guess_lexer_for_filename(path, sample, stripnl=False)
|
|
46
|
+
except ClassNotFound:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def lexer_for(path: str, sample: str = ""):
|
|
51
|
+
"""The Pygments lexer for a file, by name then content; None for plain text."""
|
|
52
|
+
return _lexer(path or "", (sample or "")[:2000])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def highlight_line(line: str, lexer, theme: str) -> Text:
|
|
56
|
+
"""One line in its language's colors, with no background of its own."""
|
|
57
|
+
if lexer is None:
|
|
58
|
+
return Text(line)
|
|
59
|
+
syntax_theme = Syntax.get_theme(theme)
|
|
60
|
+
out = Text()
|
|
61
|
+
for token, value in lexer.get_tokens(line):
|
|
62
|
+
value = value.rstrip("\n") if value.endswith("\n") else value
|
|
63
|
+
if not value:
|
|
64
|
+
continue
|
|
65
|
+
style = syntax_theme.get_style_for_token(token)
|
|
66
|
+
out.append(value, Style(color=style.color, bold=style.bold, italic=style.italic,
|
|
67
|
+
underline=style.underline))
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def diff_line(marker: str, line: str, lexer, app: Any) -> Text:
|
|
72
|
+
"""A diff row: its marker, the code in its colors, the tint of the change."""
|
|
73
|
+
dark = is_dark(app)
|
|
74
|
+
if marker == " ":
|
|
75
|
+
body = highlight_line(line, lexer, code_theme(app))
|
|
76
|
+
body.stylize("dim")
|
|
77
|
+
return Text(" ") + body
|
|
78
|
+
sign = Text(marker, style="bold green" if marker == "+" else "bold red")
|
|
79
|
+
row = sign + highlight_line(line, lexer, code_theme(app))
|
|
80
|
+
row.stylize(f"on {TINT[(dark, marker)]}")
|
|
81
|
+
return row
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def file_syntax(path: str, content: str, app: Any, background: Optional[str] = None) -> Syntax:
|
|
85
|
+
lexer = lexer_for(path, content)
|
|
86
|
+
return Syntax(content, lexer if lexer is not None else "text", theme=code_theme(app),
|
|
87
|
+
line_numbers=True, word_wrap=True, background_color=background)
|