webbee 0.3.33__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.33 → webbee-0.3.34}/CHANGELOG.md +8 -0
- {webbee-0.3.33 → webbee-0.3.34}/PKG-INFO +1 -1
- {webbee-0.3.33 → 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.33 → webbee-0.3.34}/src/webbee/home_view.py +3 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/intel/watch.py +3 -1
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/output_pane.py +7 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/repl.py +64 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/tui.py +44 -1
- webbee-0.3.34/tests/test_clipboard_read.py +84 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_repl.py +26 -0
- webbee-0.3.33/src/webbee/__init__.py +0 -1
- {webbee-0.3.33 → webbee-0.3.34}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/.gitignore +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/LICENSE +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/README.md +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/docs/superpowers/plans/2026-07-21-interactive-home-dashboard.md +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/install.sh +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/account.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/active_sessions.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/boot.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/cli.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/commands.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/config.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/consent.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/details.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/events.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/frames.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/home.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/http.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/instance_lock.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/mode_store.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/newtab_mode.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/queue_panel.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/reflow.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/remote.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/render.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/repo.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/selection.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/session.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/sessions.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/sizing.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/slots.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/steer.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/stream.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/tabs.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/thread.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/todo_panel.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/tokens.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/tools.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/update.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/urlopen.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/wallet.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/src/webbee/worktrees.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/__init__.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/conftest.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_account.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_active_sessions.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_cli.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_clipboard.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_coding_context.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_commands.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_config.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_details.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_events.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_home.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_home_view.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_instance_lock.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_intel_query.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_intel_service.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_intel_store.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_marathon.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_mode_store.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_newtab_mode.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_packaging.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_reflow.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_render.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_repo.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_session.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_sessions.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_sizing.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_slots.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_steer.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_stream.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_tabs.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_thread.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_todo_panel.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_tokens.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_tools.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_tui.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_update.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_urlopen.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_version.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_wallet.py +0 -0
- {webbee-0.3.33 → webbee-0.3.34}/tests/test_worktrees.py +0 -0
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## 0.3.33
|
|
4
12
|
|
|
5
13
|
Performance.
|
|
@@ -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
|
|
@@ -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()
|
|
@@ -534,7 +534,7 @@ def _restore_draft(buf, slot) -> None:
|
|
|
534
534
|
async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
|
|
535
535
|
stop_turn=None, queued_run=None, inject=None,
|
|
536
536
|
home_input=None, cancel_slot=None, ui_hooks=None,
|
|
537
|
-
on_switch=None, on_new=None) -> bool:
|
|
537
|
+
on_switch=None, on_new=None, on_paste=None) -> bool:
|
|
538
538
|
"""The full-screen dock: EVERYTHING visible resolves `slots.active()` AT
|
|
539
539
|
CALL TIME (W4a Task 3 — the single most structural change of the
|
|
540
540
|
multisession-tabs wave: no more one session's objects captured once at
|
|
@@ -849,6 +849,49 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
|
|
|
849
849
|
# Alt+1-style switch (footer legend reminds muscle-memory users).
|
|
850
850
|
_new_tab_click()
|
|
851
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
|
+
|
|
852
895
|
def _alt_digit_handler(d: int):
|
|
853
896
|
def _h(event):
|
|
854
897
|
_switch_to(d)
|
|
@@ -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
|
|
@@ -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") == ""
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.33"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webbee-0.3.33 → 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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|