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.
Files changed (51) hide show
  1. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/PKG-INFO +18 -10
  2. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/README.md +16 -8
  3. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/pyproject.toml +5 -4
  4. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/__init__.py +1 -1
  5. virtuai_cli-1.0.7/src/virtuai_cli/chat/clipboard.py +123 -0
  6. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/command.py +2 -2
  7. virtuai_cli-1.0.7/src/virtuai_cli/chat/devtools.py +214 -0
  8. virtuai_cli-1.0.7/src/virtuai_cli/chat/highlight.py +87 -0
  9. virtuai_cli-1.0.7/src/virtuai_cli/chat/journal.py +130 -0
  10. virtuai_cli-1.0.7/src/virtuai_cli/chat/look.py +469 -0
  11. virtuai_cli-1.0.7/src/virtuai_cli/chat/panels.py +259 -0
  12. virtuai_cli-1.0.7/src/virtuai_cli/chat/review.py +166 -0
  13. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/sse.py +6 -0
  14. virtuai_cli-1.0.7/src/virtuai_cli/chat/steps.py +516 -0
  15. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/tui.py +1098 -77
  16. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/widgets.py +342 -27
  17. virtuai_cli-1.0.7/src/virtuai_cli/command_guard.py +279 -0
  18. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/executor.py +22 -0
  19. virtuai_cli-1.0.7/src/virtuai_cli/guard_setup.py +284 -0
  20. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/main.py +46 -2
  21. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/runner.py +21 -0
  22. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/security.py +3 -1
  23. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli.egg-info/PKG-INFO +18 -10
  24. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli.egg-info/SOURCES.txt +12 -0
  25. virtuai_cli-1.0.7/tests/test_command_guard.py +143 -0
  26. virtuai_cli-1.0.7/tests/test_guard_setup.py +101 -0
  27. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/setup.cfg +0 -0
  28. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/browser.py +0 -0
  29. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/__init__.py +0 -0
  30. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/agents.py +0 -0
  31. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/ask.py +0 -0
  32. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/chat/history.py +0 -0
  33. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/config.py +0 -0
  34. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/ollama_state.py +0 -0
  35. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/omlx_state.py +0 -0
  36. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/opencode.py +0 -0
  37. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli/pull.py +0 -0
  38. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  39. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  40. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli.egg-info/requires.txt +1 -1
  41. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/src/virtuai_cli.egg-info/top_level.txt +0 -0
  42. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_browser.py +0 -0
  43. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_frame_concurrency.py +0 -0
  44. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_ollama_options.py +0 -0
  45. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_ollama_state.py +0 -0
  46. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_omlx.py +0 -0
  47. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_process_group_kill.py +0 -0
  48. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_proxy_env.py +0 -0
  49. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_pull_fallback.py +0 -0
  50. {virtuai_cli-1.0.6 → virtuai_cli-1.0.7}/tests/test_run_single_instance.py +0 -0
  51. {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.6
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
- Every workspace includes a built-in **Plan** agent. Switch to it without leaving the TUI:
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
- `/agent Plan` does the same thing. List agents with `/agents`. Switching starts a **new** conversation with that agent (history does not carry over). You can also launch Plan directly: `virtuai chat --agent Plan`. Plan explores the repo and produces a design; it does not implement. Switch back with `/agent <name>` to execute.
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`, `langchain-openai` for oMLX, and `llmfit`):
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` installed 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).
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
- Every workspace includes a built-in **Plan** agent. Switch to it without leaving the TUI:
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
- `/agent Plan` does the same thing. List agents with `/agents`. Switching starts a **new** conversation with that agent (history does not carry over). You can also launch Plan directly: `virtuai chat --agent Plan`. Plan explores the repo and produces a design; it does not implement. Switch back with `/agent <name>` to execute.
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`, `langchain-openai` for oMLX, and `llmfit`):
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` installed 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).
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.6"
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.6"
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)