webbee 0.3.32__tar.gz → 0.3.34__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.
- {webbee-0.3.32 → webbee-0.3.34}/CHANGELOG.md +19 -0
- {webbee-0.3.32 → webbee-0.3.34}/PKG-INFO +1 -1
- {webbee-0.3.32 → webbee-0.3.34}/pyproject.toml +1 -1
- webbee-0.3.34/src/webbee/__init__.py +1 -0
- webbee-0.3.34/src/webbee/clipboard_read.py +154 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/coding_context.py +13 -1
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/home_view.py +3 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/watch.py +3 -1
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/output_pane.py +7 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/repl.py +64 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/tools.py +6 -1
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/tui.py +71 -2
- webbee-0.3.34/tests/test_clipboard_read.py +84 -0
- webbee-0.3.34/tests/test_coding_context.py +31 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_repl.py +26 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_tools.py +16 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_tui.py +38 -0
- webbee-0.3.32/src/webbee/__init__.py +0 -1
- {webbee-0.3.32 → webbee-0.3.34}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/.gitignore +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/LICENSE +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/README.md +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/docs/superpowers/plans/2026-07-21-interactive-home-dashboard.md +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/install.sh +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/account.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/active_sessions.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/boot.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/cli.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/commands.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/config.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/consent.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/details.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/events.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/frames.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/home.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/http.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/instance_lock.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/mode_store.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/newtab_mode.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/queue_panel.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/reflow.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/remote.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/render.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/repo.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/selection.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/session.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/sessions.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/sizing.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/slots.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/steer.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/stream.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/tabs.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/thread.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/todo_panel.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/tokens.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/update.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/urlopen.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/wallet.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/src/webbee/worktrees.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/__init__.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/conftest.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_account.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_active_sessions.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_cli.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_clipboard.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_commands.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_config.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_details.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_events.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_home.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_home_view.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_instance_lock.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_query.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_service.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_store.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_marathon.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_mode_store.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_newtab_mode.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_packaging.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_reflow.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_render.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_repo.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_session.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_sessions.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_sizing.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_slots.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_steer.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_stream.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_tabs.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_thread.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_todo_panel.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_tokens.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_update.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_urlopen.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_version.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_wallet.py +0 -0
- {webbee-0.3.32 → webbee-0.3.34}/tests/test_worktrees.py +0 -0
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.34
|
|
4
|
+
|
|
5
|
+
Paste images with Ctrl+V. Copy a screenshot (or any image) and press Ctrl+V in
|
|
6
|
+
a session tab — Webbee Code uploads it and drops a reference into your prompt,
|
|
7
|
+
so the next thing you ask can be about that image. Text on the clipboard
|
|
8
|
+
pastes inline. Nothing is sent until you send your message; if image upload
|
|
9
|
+
isn't available the image is saved locally and referenced by path.
|
|
10
|
+
|
|
11
|
+
## 0.3.33
|
|
12
|
+
|
|
13
|
+
Performance.
|
|
14
|
+
|
|
15
|
+
- When nothing is happening, the terminal now wakes about once a second
|
|
16
|
+
instead of four times a second — easier on the CPU and the battery — while
|
|
17
|
+
staying instant the moment a task runs or you're drag-scrolling.
|
|
18
|
+
- The agent's file search and per-task project snapshot no longer crawl
|
|
19
|
+
`node_modules`, `vendor`, `dist`, `build`, and friends, so both stay fast on
|
|
20
|
+
large repositories.
|
|
21
|
+
|
|
3
22
|
## 0.3.32
|
|
4
23
|
|
|
5
24
|
- Fixed the screen getting scrambled by stray output from background helper
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.34"
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Read the OS clipboard OUT-OF-BAND for Ctrl+V paste (W3 Wave A).
|
|
2
|
+
|
|
3
|
+
A terminal's bracketed paste is text-only — a clipboard IMAGE never reaches the
|
|
4
|
+
app that way — so an image must be pulled from the OS clipboard via the
|
|
5
|
+
platform tool. Same discipline as `clipboard.py` (copy-out): `shutil.which`-
|
|
6
|
+
gated, bounded timeout, output CAPTURED so it never inherits the dock's tty
|
|
7
|
+
(the 0.3.32 lesson: a chatty child scrambles the full-screen renderer), and it
|
|
8
|
+
NEVER raises. macOS + Linux are the verified paths; Windows is best-effort and
|
|
9
|
+
UNVERIFIED (no Windows host to test on) — it fails soft to None."""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ClipboardItem:
|
|
20
|
+
kind: str # "image" | "text"
|
|
21
|
+
data: object # bytes (PNG) for image, str for text
|
|
22
|
+
name: str = "" # suggested filename for an image
|
|
23
|
+
mime: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _run(cmd):
|
|
27
|
+
"""Captured, bounded, never-raises subprocess. `capture_output=True` keeps
|
|
28
|
+
stdout/stderr OFF the dock's tty. Returns CompletedProcess or None."""
|
|
29
|
+
try:
|
|
30
|
+
return subprocess.run(cmd, capture_output=True, timeout=2)
|
|
31
|
+
except Exception:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read_via_tempfile(argv_for_path) -> "bytes | None":
|
|
36
|
+
"""Run a tool that WRITES a PNG to a temp path (osascript / PowerShell),
|
|
37
|
+
then read+delete it. `argv_for_path(path)` returns the argv. Never raises."""
|
|
38
|
+
import os
|
|
39
|
+
import tempfile
|
|
40
|
+
fd, path = tempfile.mkstemp(suffix=".png")
|
|
41
|
+
os.close(fd)
|
|
42
|
+
try:
|
|
43
|
+
p = _run(argv_for_path(path))
|
|
44
|
+
if p is not None and p.returncode == 0 and os.path.getsize(path) > 0:
|
|
45
|
+
with open(path, "rb") as f:
|
|
46
|
+
return f.read() or None
|
|
47
|
+
return None
|
|
48
|
+
except Exception:
|
|
49
|
+
return None
|
|
50
|
+
finally:
|
|
51
|
+
try:
|
|
52
|
+
os.remove(path)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _mac_image() -> "bytes | None":
|
|
58
|
+
if shutil.which("pngpaste"):
|
|
59
|
+
p = _run(["pngpaste", "-"])
|
|
60
|
+
if p is not None and p.returncode == 0 and p.stdout:
|
|
61
|
+
return p.stdout
|
|
62
|
+
if not shutil.which("osascript"):
|
|
63
|
+
return None
|
|
64
|
+
# osascript returns non-zero when the clipboard holds no «class PNGf» image.
|
|
65
|
+
def _argv(path):
|
|
66
|
+
script = ('set p to (POSIX file "%s")\n'
|
|
67
|
+
'set d to (the clipboard as «class PNGf»)\n'
|
|
68
|
+
'set fh to open for access p with write permission\n'
|
|
69
|
+
'write d to fh\nclose access fh' % path)
|
|
70
|
+
return ["osascript", "-e", script]
|
|
71
|
+
return _read_via_tempfile(_argv)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _linux_image() -> "bytes | None":
|
|
75
|
+
if shutil.which("wl-paste"):
|
|
76
|
+
types = _run(["wl-paste", "--list-types"])
|
|
77
|
+
if types is not None and types.returncode == 0 and b"image/png" in (types.stdout or b""):
|
|
78
|
+
p = _run(["wl-paste", "--type", "image/png"])
|
|
79
|
+
if p is not None and p.returncode == 0 and p.stdout:
|
|
80
|
+
return p.stdout
|
|
81
|
+
if shutil.which("xclip"):
|
|
82
|
+
tgt = _run(["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"])
|
|
83
|
+
if tgt is not None and tgt.returncode == 0 and b"image/png" in (tgt.stdout or b""):
|
|
84
|
+
p = _run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"])
|
|
85
|
+
if p is not None and p.returncode == 0 and p.stdout:
|
|
86
|
+
return p.stdout
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _windows_image() -> "bytes | None":
|
|
91
|
+
# UNVERIFIED (no Windows host). Best-effort; fail-soft to None.
|
|
92
|
+
if not shutil.which("powershell"):
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def _argv(path):
|
|
96
|
+
ps = ("Add-Type -AssemblyName System.Windows.Forms;"
|
|
97
|
+
"$i=[System.Windows.Forms.Clipboard]::GetImage();"
|
|
98
|
+
"if($i){$i.Save('%s')}" % path.replace("\\", "\\\\"))
|
|
99
|
+
return ["powershell", "-NoProfile", "-Command", ps]
|
|
100
|
+
return _read_via_tempfile(_argv)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def read_clipboard_image() -> "bytes | None":
|
|
104
|
+
"""Raw PNG bytes on the clipboard, or None. Platform-dispatched, captured
|
|
105
|
+
(tty-safe), never raises."""
|
|
106
|
+
try:
|
|
107
|
+
if sys.platform == "darwin":
|
|
108
|
+
return _mac_image()
|
|
109
|
+
if sys.platform == "win32":
|
|
110
|
+
return _windows_image()
|
|
111
|
+
return _linux_image()
|
|
112
|
+
except Exception:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _text_cmd() -> "list[str] | None":
|
|
117
|
+
if sys.platform == "darwin":
|
|
118
|
+
return ["pbpaste"] if shutil.which("pbpaste") else None
|
|
119
|
+
if shutil.which("wl-paste"):
|
|
120
|
+
return ["wl-paste", "--no-newline"]
|
|
121
|
+
if shutil.which("xclip"):
|
|
122
|
+
return ["xclip", "-selection", "clipboard", "-o"]
|
|
123
|
+
if sys.platform == "win32" and shutil.which("powershell"):
|
|
124
|
+
return ["powershell", "-NoProfile", "-Command", "Get-Clipboard"]
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def read_clipboard_text() -> "str | None":
|
|
129
|
+
"""The clipboard's text, or None. Captured (tty-safe), never raises."""
|
|
130
|
+
cmd = _text_cmd()
|
|
131
|
+
if cmd is None:
|
|
132
|
+
return None
|
|
133
|
+
p = _run(cmd)
|
|
134
|
+
if p is not None and p.returncode == 0 and p.stdout:
|
|
135
|
+
try:
|
|
136
|
+
return p.stdout.decode("utf-8", "replace")
|
|
137
|
+
except Exception:
|
|
138
|
+
return None
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def read_clipboard(ts: str) -> "ClipboardItem | None":
|
|
143
|
+
"""One paste's worth of clipboard content: an IMAGE if present (PNG bytes,
|
|
144
|
+
named `pasted-<ts>.png`), else TEXT, else None. `ts` is a caller-supplied
|
|
145
|
+
timestamp string for the image name (the dock has no wall-clock in a pure
|
|
146
|
+
function, so it's passed in)."""
|
|
147
|
+
img = read_clipboard_image()
|
|
148
|
+
if img:
|
|
149
|
+
return ClipboardItem(kind="image", data=img, name=f"pasted-{ts}.png",
|
|
150
|
+
mime="image/png")
|
|
151
|
+
txt = read_clipboard_text()
|
|
152
|
+
if txt:
|
|
153
|
+
return ClipboardItem(kind="text", data=txt)
|
|
154
|
+
return None
|
|
@@ -5,6 +5,17 @@ proof-of-done command a marathon carries in that context."""
|
|
|
5
5
|
import os
|
|
6
6
|
import subprocess
|
|
7
7
|
|
|
8
|
+
# Heavy dependency/build dirs the file-tree walk and the agent `grep` tool
|
|
9
|
+
# must NEVER descend: they blow up walk time on real repos and (with the tree's
|
|
10
|
+
# 200-file cap) would otherwise fill the snapshot with dependency junk instead
|
|
11
|
+
# of the user's own code. `.git` + all dotdirs are pruned separately by the
|
|
12
|
+
# callers; these are the non-dot offenders. Shared with tools._t_grep.
|
|
13
|
+
WALK_IGNORE_DIRS = frozenset({
|
|
14
|
+
"node_modules", "vendor", "dist", "build", "target", "__pycache__",
|
|
15
|
+
".git", ".venv", "venv", ".next", ".cache", ".tox", ".mypy_cache",
|
|
16
|
+
".pytest_cache",
|
|
17
|
+
})
|
|
18
|
+
|
|
8
19
|
|
|
9
20
|
def build_coding_context(workspace_root: str, intel=None) -> dict:
|
|
10
21
|
"""Snapshot handed to the cloud brain: cwd (realpath), `git status -sb`
|
|
@@ -23,7 +34,8 @@ def build_coding_context(workspace_root: str, intel=None) -> dict:
|
|
|
23
34
|
git = ""
|
|
24
35
|
paths = []
|
|
25
36
|
for dirpath, dirnames, filenames in os.walk(cwd):
|
|
26
|
-
dirnames[:] = [d for d in dirnames
|
|
37
|
+
dirnames[:] = [d for d in dirnames
|
|
38
|
+
if d not in WALK_IGNORE_DIRS and not d.startswith(".")]
|
|
27
39
|
for fn in filenames:
|
|
28
40
|
paths.append(os.path.relpath(os.path.join(dirpath, fn), cwd))
|
|
29
41
|
if len(paths) >= 200:
|
|
@@ -368,6 +368,9 @@ class HomeView:
|
|
|
368
368
|
def flash(self) -> str:
|
|
369
369
|
return self._out.flash()
|
|
370
370
|
|
|
371
|
+
def flash_note(self, msg: str, secs: float = 4.0) -> None:
|
|
372
|
+
self._out.flash_note(msg, secs)
|
|
373
|
+
|
|
371
374
|
def scroll(self, delta: int) -> None:
|
|
372
375
|
# Wheel (via the control) and PageUp/PageDown (tui routes here) move the
|
|
373
376
|
# top visible line; clamped so you can't scroll past the ends.
|
|
@@ -11,7 +11,9 @@ def _ignored(path: str) -> bool:
|
|
|
11
11
|
filter works on Windows too (watchfiles yields backslash paths there --
|
|
12
12
|
unfiltered .git churn re-indexed the repo on every checkpoint)."""
|
|
13
13
|
p = path.replace(os.sep, "/").replace("\\", "/")
|
|
14
|
-
|
|
14
|
+
# `.webbee/` = our own per-workspace scratch (pasted images, gitignore) —
|
|
15
|
+
# never re-index/checkpoint on a paste (W3).
|
|
16
|
+
return "/.git/" in p or "/node_modules/" in p or "/.webbee/" in p
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
async def watch_workspace(root: str, on_change) -> None:
|
|
@@ -335,6 +335,13 @@ class OutputPane:
|
|
|
335
335
|
import time as _t
|
|
336
336
|
return self.copy_flash if _t.monotonic() < self._flash_until else ""
|
|
337
337
|
|
|
338
|
+
def flash_note(self, msg: str, secs: float = 4.0) -> None:
|
|
339
|
+
"""Show a transient toolbar note (paste progress, hints) — same channel
|
|
340
|
+
as the copy toast, reused so there's one flash mechanism."""
|
|
341
|
+
import time as _t
|
|
342
|
+
self.copy_flash = msg
|
|
343
|
+
self._flash_until = _t.monotonic() + secs
|
|
344
|
+
|
|
338
345
|
# ---- W2 Task 8: selection capture past the pane's own Window --------
|
|
339
346
|
def forward_mouse(self, ev, clamp: str = "bottom") -> bool:
|
|
340
347
|
"""Public seam neighbor windows call FIRST (via `tui._forwarding` /
|
|
@@ -676,6 +676,43 @@ def _schedule_home_refill(slots: SlotManager, idx: int, fill_kwargs: dict, *,
|
|
|
676
676
|
return True
|
|
677
677
|
|
|
678
678
|
|
|
679
|
+
def _save_pasted(workspace: str, name: str, data: bytes) -> str:
|
|
680
|
+
"""Save a pasted image into `<workspace>/.webbee/pasted/<name>` (that dir
|
|
681
|
+
ships a `.gitignore` of `*` so pasted files never pollute the repo or its
|
|
682
|
+
shadow-git). Returns the saved path, or "" on any failure. Never raises."""
|
|
683
|
+
try:
|
|
684
|
+
base = os.path.join(workspace, ".webbee")
|
|
685
|
+
pdir = os.path.join(base, "pasted")
|
|
686
|
+
os.makedirs(pdir, exist_ok=True)
|
|
687
|
+
gi = os.path.join(base, ".gitignore")
|
|
688
|
+
if not os.path.exists(gi):
|
|
689
|
+
with open(gi, "w", encoding="utf-8") as f:
|
|
690
|
+
f.write("*\n")
|
|
691
|
+
path = os.path.join(pdir, name)
|
|
692
|
+
with open(path, "wb") as f:
|
|
693
|
+
f.write(data)
|
|
694
|
+
return path
|
|
695
|
+
except Exception:
|
|
696
|
+
return ""
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _extract_file_id(resp) -> str:
|
|
700
|
+
"""Pull `file_id` from a file-reader `receive_files` response. Canon: it
|
|
701
|
+
lives at `data.items[].file_id` (== `.id`), NOT `data.received` (the panel's
|
|
702
|
+
Round-3 lesson). Handles dict + object shapes; "" if absent."""
|
|
703
|
+
try:
|
|
704
|
+
data = resp.get("data") if isinstance(resp, dict) else getattr(resp, "data", None)
|
|
705
|
+
items = data.get("items") if isinstance(data, dict) else getattr(data, "items", None)
|
|
706
|
+
if items:
|
|
707
|
+
it = items[0]
|
|
708
|
+
if isinstance(it, dict):
|
|
709
|
+
return str(it.get("file_id") or it.get("id") or "")
|
|
710
|
+
return str(getattr(it, "file_id", "") or getattr(it, "id", "") or "")
|
|
711
|
+
except Exception:
|
|
712
|
+
pass
|
|
713
|
+
return ""
|
|
714
|
+
|
|
715
|
+
|
|
679
716
|
async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None, read_line=input,
|
|
680
717
|
agent_factory=None, auth=None, account_fetcher=None,
|
|
681
718
|
sessions_client=None, intel_factory=None, shadow_factory=None) -> None:
|
|
@@ -1337,6 +1374,32 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1337
1374
|
from prompt_toolkit.application import get_app
|
|
1338
1375
|
get_app().exit()
|
|
1339
1376
|
|
|
1377
|
+
async def _on_paste(ws: str, name: str, data: bytes) -> str:
|
|
1378
|
+
# W3 Wave A: save the pasted image into the tab's workspace
|
|
1379
|
+
# then upload it through the SAME file-reader door the panel
|
|
1380
|
+
# and Telegram use; return the reference to drop into the
|
|
1381
|
+
# task. Degrades honestly on 402 / no cloud (local path +
|
|
1382
|
+
# note). Never raises.
|
|
1383
|
+
import base64
|
|
1384
|
+
saved = _save_pasted(ws, name, data)
|
|
1385
|
+
try:
|
|
1386
|
+
from imperal_mcp.client import ImperalClient
|
|
1387
|
+
client = ImperalClient(cfg, token_provider)
|
|
1388
|
+
b64 = base64.b64encode(data).decode("ascii")
|
|
1389
|
+
resp = await client.run_tool(
|
|
1390
|
+
"file-reader", "receive_files",
|
|
1391
|
+
{"files": [{"name": name, "mime_type": "image/png",
|
|
1392
|
+
"data_base64": b64}]})
|
|
1393
|
+
fid = _extract_file_id(resp)
|
|
1394
|
+
if fid:
|
|
1395
|
+
return f"📎 {name} (file_id={fid})"
|
|
1396
|
+
except Exception:
|
|
1397
|
+
pass
|
|
1398
|
+
if saved:
|
|
1399
|
+
return (f"📎 {name} — saved to {saved}; a file-reader "
|
|
1400
|
+
"plan is needed to send it to the model")
|
|
1401
|
+
return f"📎 {name} — could not upload"
|
|
1402
|
+
|
|
1340
1403
|
try:
|
|
1341
1404
|
ok = await tui.run_session(
|
|
1342
1405
|
slots=slots, on_line=_on_line, on_cycle=_cycle,
|
|
@@ -1359,6 +1422,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1359
1422
|
spawn_poller=_spawn_slot_poller),
|
|
1360
1423
|
on_switch=lambda idx: _schedule_home_refill(slots, idx, home_fill_kwargs),
|
|
1361
1424
|
on_new=lambda: _open_new_tab(),
|
|
1425
|
+
on_paste=_on_paste,
|
|
1362
1426
|
)
|
|
1363
1427
|
finally:
|
|
1364
1428
|
_cancel_background()
|
|
@@ -327,12 +327,17 @@ class LocalToolExecutor:
|
|
|
327
327
|
return {"ok": proc.returncode == 0, "content": out or f"(exit {proc.returncode})"}
|
|
328
328
|
|
|
329
329
|
def _t_grep(self, a: dict) -> dict:
|
|
330
|
+
from webbee.coding_context import WALK_IGNORE_DIRS
|
|
330
331
|
pat = re.compile(a["pattern"])
|
|
331
332
|
base = self.resolve_in_workspace(a.get("path", "."))
|
|
332
333
|
hits = []
|
|
333
|
-
for dp,
|
|
334
|
+
for dp, dns, fns in os.walk(base):
|
|
334
335
|
if _is_git_dir(dp):
|
|
336
|
+
dns[:] = []
|
|
335
337
|
continue
|
|
338
|
+
# Prune heavy dependency/build dirs (node_modules, vendor, dist,
|
|
339
|
+
# …) so a grep on a real repo doesn't crawl megabytes of deps.
|
|
340
|
+
dns[:] = [d for d in dns if d not in WALK_IGNORE_DIRS]
|
|
336
341
|
for fn in fns:
|
|
337
342
|
fp = os.path.join(dp, fn)
|
|
338
343
|
try:
|
|
@@ -211,6 +211,32 @@ def _width_watch(pane, app) -> None:
|
|
|
211
211
|
pass
|
|
212
212
|
|
|
213
213
|
|
|
214
|
+
def _ticker_busy(slots, is_busy) -> bool:
|
|
215
|
+
"""Whether the dock's animation loop must stay at the FAST (0.25s) cadence:
|
|
216
|
+
a turn is running, a copy-flash toast is live, OR an edge-drag auto-scroll
|
|
217
|
+
is in flight (`pane._edge_drag` — a drag-select past the viewport edge that
|
|
218
|
+
`pane.edge_tick()` keeps scrolling every tick). All three need smooth ~4x/s
|
|
219
|
+
updates; miss the edge-drag one and idle drag-scrolling crawls 4x slower.
|
|
220
|
+
Otherwise the loop is idle → the caller uses the slow 1.0s cadence."""
|
|
221
|
+
try:
|
|
222
|
+
pane = slots.active().pane
|
|
223
|
+
if bool(pane.flash()) or bool(getattr(pane, "_edge_drag", 0)):
|
|
224
|
+
return True
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
return bool(is_busy())
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _tick_interval(busy: bool) -> float:
|
|
231
|
+
"""The dock's animation-loop sleep. 0.25s while a turn is running (or a
|
|
232
|
+
copy-flash is live) so the spinner/elapsed-clock animate smoothly; 1.0s
|
|
233
|
+
when fully idle, so the loop wakes 1×/s instead of 4×/s (less CPU/battery)
|
|
234
|
+
with no visible cost — an idle tick only resize-detects + re-syncs hover,
|
|
235
|
+
both of which tolerate a 1s lag (and a turn's first frame still shows
|
|
236
|
+
instantly via the submit's own invalidate)."""
|
|
237
|
+
return 0.25 if busy else 1.0
|
|
238
|
+
|
|
239
|
+
|
|
214
240
|
def _tick_once(slots, app, is_busy) -> None:
|
|
215
241
|
"""One iteration of run_session's `_ticker` loop, extracted module-level
|
|
216
242
|
so the wiring itself is directly unit-testable (an `async def` infinite
|
|
@@ -508,7 +534,7 @@ def _restore_draft(buf, slot) -> None:
|
|
|
508
534
|
async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
|
|
509
535
|
stop_turn=None, queued_run=None, inject=None,
|
|
510
536
|
home_input=None, cancel_slot=None, ui_hooks=None,
|
|
511
|
-
on_switch=None, on_new=None) -> bool:
|
|
537
|
+
on_switch=None, on_new=None, on_paste=None) -> bool:
|
|
512
538
|
"""The full-screen dock: EVERYTHING visible resolves `slots.active()` AT
|
|
513
539
|
CALL TIME (W4a Task 3 — the single most structural change of the
|
|
514
540
|
multisession-tabs wave: no more one session's objects captured once at
|
|
@@ -823,6 +849,49 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
|
|
|
823
849
|
# Alt+1-style switch (footer legend reminds muscle-memory users).
|
|
824
850
|
_new_tab_click()
|
|
825
851
|
|
|
852
|
+
@kb.add("c-v")
|
|
853
|
+
def _paste_key(event):
|
|
854
|
+
# 0.3.34 (W3 Wave A): Ctrl-V pastes the OS clipboard. An IMAGE is read
|
|
855
|
+
# OUT-OF-BAND (a terminal's bracketed paste is text-only), then saved +
|
|
856
|
+
# uploaded via the file-reader door (repl's on_paste) and its reference
|
|
857
|
+
# dropped into the input; a text clipboard is inserted inline. Normal
|
|
858
|
+
# text paste (Cmd-V / Ctrl-Shift-V → bracketed paste) is a DIFFERENT
|
|
859
|
+
# key and is unaffected. Home has no workspace/agent → guide, not crash.
|
|
860
|
+
import time as _t
|
|
861
|
+
from webbee.clipboard_read import read_clipboard
|
|
862
|
+
slot = _a()
|
|
863
|
+
pane = _pane()
|
|
864
|
+
if on_paste is None or slot.kind == "home" or not getattr(slot, "workspace", ""):
|
|
865
|
+
pane.flash_note("📎 open a session tab to paste a file")
|
|
866
|
+
event.app.invalidate()
|
|
867
|
+
return
|
|
868
|
+
item = read_clipboard(_t.strftime("%Y%m%d-%H%M%S", _t.gmtime()))
|
|
869
|
+
if item is None:
|
|
870
|
+
pane.flash_note("clipboard is empty")
|
|
871
|
+
event.app.invalidate()
|
|
872
|
+
return
|
|
873
|
+
if item.kind == "text":
|
|
874
|
+
buf.insert_text(item.data)
|
|
875
|
+
return
|
|
876
|
+
pane.flash_note(f"📎 uploading {item.name}…", secs=30.0)
|
|
877
|
+
event.app.invalidate()
|
|
878
|
+
|
|
879
|
+
async def _do_paste():
|
|
880
|
+
ref = ""
|
|
881
|
+
try:
|
|
882
|
+
ref = await on_paste(slot.workspace, item.name, item.data)
|
|
883
|
+
except Exception:
|
|
884
|
+
ref = ""
|
|
885
|
+
if ref:
|
|
886
|
+
sep = "" if (not buf.text or buf.text.endswith(" ")) else " "
|
|
887
|
+
buf.insert_text(sep + ref + " ")
|
|
888
|
+
pane.flash_note(f"📎 {item.name}")
|
|
889
|
+
else:
|
|
890
|
+
pane.flash_note("📎 paste failed")
|
|
891
|
+
event.app.invalidate()
|
|
892
|
+
|
|
893
|
+
event.app.create_background_task(_do_paste())
|
|
894
|
+
|
|
826
895
|
def _alt_digit_handler(d: int):
|
|
827
896
|
def _h(event):
|
|
828
897
|
_switch_to(d)
|
|
@@ -1272,7 +1341,7 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
|
|
|
1272
1341
|
# is_busy this ticker feeds it, since the old top-level is_busy
|
|
1273
1342
|
# param died with the rest of the sink-shaped params.
|
|
1274
1343
|
while True:
|
|
1275
|
-
await asyncio.sleep(
|
|
1344
|
+
await asyncio.sleep(_tick_interval(_ticker_busy(slots, _busy_live)))
|
|
1276
1345
|
_sync_hover_mode()
|
|
1277
1346
|
_tick_once(slots, app, _busy_live)
|
|
1278
1347
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import webbee.clipboard_read as cr
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _CP:
|
|
8
|
+
def __init__(self, rc=0, out=b""):
|
|
9
|
+
self.returncode = rc
|
|
10
|
+
self.stdout = out
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_run_captures_output_tty_safe(monkeypatch):
|
|
14
|
+
# The 0.3.32 lesson: clipboard subprocesses must NOT inherit the dock tty.
|
|
15
|
+
seen = {}
|
|
16
|
+
|
|
17
|
+
def fake(cmd, **kw):
|
|
18
|
+
seen.update(kw)
|
|
19
|
+
return _CP(0, b"x")
|
|
20
|
+
|
|
21
|
+
monkeypatch.setattr(subprocess, "run", fake)
|
|
22
|
+
cr._run(["echo"])
|
|
23
|
+
assert seen.get("capture_output") is True
|
|
24
|
+
assert seen.get("timeout") == 2
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_run_never_raises(monkeypatch):
|
|
28
|
+
monkeypatch.setattr(subprocess, "run", lambda *a, **k: (_ for _ in ()).throw(RuntimeError()))
|
|
29
|
+
assert cr._run(["x"]) is None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_mac_image_via_pngpaste(monkeypatch):
|
|
33
|
+
monkeypatch.setattr(sys, "platform", "darwin")
|
|
34
|
+
monkeypatch.setattr(cr.shutil, "which", lambda n: "/p" if n == "pngpaste" else None)
|
|
35
|
+
monkeypatch.setattr(subprocess, "run", lambda cmd, **k: _CP(0, b"\x89PNGdata"))
|
|
36
|
+
assert cr.read_clipboard_image() == b"\x89PNGdata"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_image_none_when_no_tool(monkeypatch):
|
|
40
|
+
monkeypatch.setattr(sys, "platform", "darwin")
|
|
41
|
+
monkeypatch.setattr(cr.shutil, "which", lambda n: None)
|
|
42
|
+
assert cr.read_clipboard_image() is None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_linux_image_requires_png_in_targets(monkeypatch):
|
|
46
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
47
|
+
monkeypatch.setattr(cr.shutil, "which", lambda n: "/x" if n == "xclip" else None)
|
|
48
|
+
|
|
49
|
+
def fake(cmd, **k):
|
|
50
|
+
if "TARGETS" in cmd:
|
|
51
|
+
return _CP(0, b"TARGETS\nimage/png\nUTF8_STRING")
|
|
52
|
+
return _CP(0, b"PNGDATA")
|
|
53
|
+
|
|
54
|
+
monkeypatch.setattr(subprocess, "run", fake)
|
|
55
|
+
assert cr.read_clipboard_image() == b"PNGDATA"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_linux_image_none_when_no_png_target(monkeypatch):
|
|
59
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
60
|
+
monkeypatch.setattr(cr.shutil, "which", lambda n: "/x" if n == "xclip" else None)
|
|
61
|
+
monkeypatch.setattr(subprocess, "run", lambda cmd, **k: _CP(0, b"TARGETS\nUTF8_STRING"))
|
|
62
|
+
assert cr.read_clipboard_image() is None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_read_clipboard_text(monkeypatch):
|
|
66
|
+
monkeypatch.setattr(sys, "platform", "darwin")
|
|
67
|
+
monkeypatch.setattr(cr.shutil, "which", lambda n: "/p" if n == "pbpaste" else None)
|
|
68
|
+
monkeypatch.setattr(subprocess, "run", lambda cmd, **k: _CP(0, b"hello text"))
|
|
69
|
+
assert cr.read_clipboard_text() == "hello text"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_read_clipboard_prefers_image_then_text_then_none(monkeypatch):
|
|
73
|
+
monkeypatch.setattr(cr, "read_clipboard_image", lambda: b"IMG")
|
|
74
|
+
monkeypatch.setattr(cr, "read_clipboard_text", lambda: "txt")
|
|
75
|
+
item = cr.read_clipboard("20260721")
|
|
76
|
+
assert item.kind == "image" and item.data == b"IMG"
|
|
77
|
+
assert item.name == "pasted-20260721.png" and item.mime == "image/png"
|
|
78
|
+
|
|
79
|
+
monkeypatch.setattr(cr, "read_clipboard_image", lambda: None)
|
|
80
|
+
item = cr.read_clipboard("x")
|
|
81
|
+
assert item.kind == "text" and item.data == "txt"
|
|
82
|
+
|
|
83
|
+
monkeypatch.setattr(cr, "read_clipboard_text", lambda: None)
|
|
84
|
+
assert cr.read_clipboard("x") is None
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from webbee.coding_context import WALK_IGNORE_DIRS, build_coding_context
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_walk_ignore_dirs_has_the_heavy_hitters():
|
|
5
|
+
for d in ("node_modules", "vendor", "dist", "build", "target", "__pycache__"):
|
|
6
|
+
assert d in WALK_IGNORE_DIRS
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_build_coding_context_prunes_heavy_dirs(tmp_path):
|
|
10
|
+
# W6: the per-turn file-tree walk must not descend node_modules/etc. — on a
|
|
11
|
+
# real repo it's slow and (with the 200-file cap) would fill the snapshot
|
|
12
|
+
# with dependency junk instead of the user's own code.
|
|
13
|
+
(tmp_path / "src").mkdir()
|
|
14
|
+
(tmp_path / "src" / "a.py").write_text("x\n")
|
|
15
|
+
nm = tmp_path / "node_modules" / "pkg"
|
|
16
|
+
nm.mkdir(parents=True)
|
|
17
|
+
(nm / "index.js").write_text("y\n")
|
|
18
|
+
tree = build_coding_context(str(tmp_path))["tree"]
|
|
19
|
+
assert "src/a.py" in tree
|
|
20
|
+
assert "node_modules" not in tree
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_build_coding_context_still_prunes_dotdirs(tmp_path):
|
|
24
|
+
(tmp_path / "src").mkdir()
|
|
25
|
+
(tmp_path / "src" / "a.py").write_text("x\n")
|
|
26
|
+
hidden = tmp_path / ".secret"
|
|
27
|
+
hidden.mkdir()
|
|
28
|
+
(hidden / "k.txt").write_text("y\n")
|
|
29
|
+
tree = build_coding_context(str(tmp_path))["tree"]
|
|
30
|
+
assert "src/a.py" in tree
|
|
31
|
+
assert ".secret" not in tree
|
|
@@ -2815,3 +2815,29 @@ def test_remote_mode_flip_targets_polled_slot_not_active(monkeypatch):
|
|
|
2815
2815
|
confirm_src = inspect.getsource(repl_mod._confirm_autopilot)
|
|
2816
2816
|
assert "slots.active()" not in confirm_src
|
|
2817
2817
|
assert "def _confirm_autopilot(slot" in confirm_src
|
|
2818
|
+
|
|
2819
|
+
|
|
2820
|
+
def test_extract_file_id_reads_items_path():
|
|
2821
|
+
# Canon: data.items[].file_id (== .id), NOT data.received.
|
|
2822
|
+
from webbee.repl import _extract_file_id
|
|
2823
|
+
assert _extract_file_id({"data": {"items": [{"file_id": "abc", "id": "xyz"}]}}) == "abc"
|
|
2824
|
+
assert _extract_file_id({"data": {"items": [{"id": "xyz"}]}}) == "xyz"
|
|
2825
|
+
assert _extract_file_id({"data": {"items": []}}) == ""
|
|
2826
|
+
assert _extract_file_id({"data": {}}) == ""
|
|
2827
|
+
assert _extract_file_id({}) == ""
|
|
2828
|
+
assert _extract_file_id(None) == ""
|
|
2829
|
+
|
|
2830
|
+
|
|
2831
|
+
def test_save_pasted_writes_file_and_gitignore(tmp_path):
|
|
2832
|
+
import os as _os
|
|
2833
|
+
from webbee.repl import _save_pasted
|
|
2834
|
+
p = _save_pasted(str(tmp_path), "pasted-x.png", b"\x89PNG")
|
|
2835
|
+
assert p and _os.path.isfile(p)
|
|
2836
|
+
with open(p, "rb") as f:
|
|
2837
|
+
assert f.read() == b"\x89PNG"
|
|
2838
|
+
assert (tmp_path / ".webbee" / ".gitignore").read_text().strip() == "*"
|
|
2839
|
+
|
|
2840
|
+
|
|
2841
|
+
def test_save_pasted_never_raises_on_bad_dir():
|
|
2842
|
+
from webbee.repl import _save_pasted
|
|
2843
|
+
assert _save_pasted("/dev/null/nope", "x.png", b"x") == ""
|
|
@@ -49,6 +49,22 @@ def test_grep_skips_git_dir(tmp_path):
|
|
|
49
49
|
assert r["ok"] and "a.py" in r["content"] and ".git" not in r["content"]
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
def test_grep_prunes_heavy_dirs(tmp_path):
|
|
53
|
+
# W6: a code grep must never crawl node_modules/vendor/dist/etc. — huge and
|
|
54
|
+
# never what the user meant. A match in src surfaces; one in node_modules
|
|
55
|
+
# does not (the walk is pruned, not just filtered).
|
|
56
|
+
ex = _ex(tmp_path)
|
|
57
|
+
(tmp_path / "src").mkdir()
|
|
58
|
+
(tmp_path / "src" / "a.py").write_text("needle\n")
|
|
59
|
+
nm = tmp_path / "node_modules" / "pkg"
|
|
60
|
+
nm.mkdir(parents=True)
|
|
61
|
+
(nm / "index.js").write_text("needle\n")
|
|
62
|
+
r = ex.run("grep", {"pattern": "needle"})
|
|
63
|
+
assert r["ok"]
|
|
64
|
+
assert "a.py" in r["content"]
|
|
65
|
+
assert "node_modules" not in r["content"]
|
|
66
|
+
|
|
67
|
+
|
|
52
68
|
def test_grep_skips_git_dir_windows_style():
|
|
53
69
|
# The os.walk-driven .git skip in _t_grep normalizes dp with os.sep so it
|
|
54
70
|
# also matches on Windows, where os.walk yields backslash paths (an
|
|
@@ -5206,3 +5206,41 @@ def test_hover_scoping_present_and_home_only():
|
|
|
5206
5206
|
src = inspect.getsource(tui.run_session)
|
|
5207
5207
|
assert "?1003h" in src and "?1003l" in src
|
|
5208
5208
|
assert "_sync_hover_mode" in src
|
|
5209
|
+
|
|
5210
|
+
|
|
5211
|
+
def test_tick_interval_fast_when_busy_slow_when_idle():
|
|
5212
|
+
# W6: the animation loop wakes 4x/s only while busy/flash; 1x/s when idle.
|
|
5213
|
+
from webbee.tui import _tick_interval
|
|
5214
|
+
assert _tick_interval(True) == 0.25
|
|
5215
|
+
assert _tick_interval(False) == 1.0
|
|
5216
|
+
|
|
5217
|
+
|
|
5218
|
+
def test_ticker_busy_includes_turn_flash_and_edge_drag():
|
|
5219
|
+
# W6 review fix: the fast-cadence predicate must fire for a running turn,
|
|
5220
|
+
# a live copy-flash, AND an in-flight edge-drag auto-scroll (else idle
|
|
5221
|
+
# drag-scrolling crawls 4x slower). A pane lacking _edge_drag (HomeView)
|
|
5222
|
+
# must not crash.
|
|
5223
|
+
from webbee.tui import _ticker_busy
|
|
5224
|
+
|
|
5225
|
+
class _Pane:
|
|
5226
|
+
def __init__(self, flash="", edge=0):
|
|
5227
|
+
self._flash, self._edge_drag = flash, edge
|
|
5228
|
+
def flash(self):
|
|
5229
|
+
return self._flash
|
|
5230
|
+
|
|
5231
|
+
class _Slots:
|
|
5232
|
+
def __init__(self, pane):
|
|
5233
|
+
self._p = pane
|
|
5234
|
+
def active(self):
|
|
5235
|
+
return type("S", (), {"pane": self._p})()
|
|
5236
|
+
|
|
5237
|
+
idle, running = (lambda: False), (lambda: True)
|
|
5238
|
+
assert _ticker_busy(_Slots(_Pane()), idle) is False
|
|
5239
|
+
assert _ticker_busy(_Slots(_Pane()), running) is True
|
|
5240
|
+
assert _ticker_busy(_Slots(_Pane(flash="copied")), idle) is True
|
|
5241
|
+
assert _ticker_busy(_Slots(_Pane(edge=1)), idle) is True
|
|
5242
|
+
|
|
5243
|
+
class _Bare:
|
|
5244
|
+
def flash(self):
|
|
5245
|
+
return ""
|
|
5246
|
+
assert _ticker_busy(_Slots(_Bare()), idle) is False
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.32"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webbee-0.3.32 → webbee-0.3.34}/docs/superpowers/plans/2026-07-21-interactive-home-dashboard.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|