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.
Files changed (121) hide show
  1. {webbee-0.3.32 → webbee-0.3.34}/CHANGELOG.md +19 -0
  2. {webbee-0.3.32 → webbee-0.3.34}/PKG-INFO +1 -1
  3. {webbee-0.3.32 → webbee-0.3.34}/pyproject.toml +1 -1
  4. webbee-0.3.34/src/webbee/__init__.py +1 -0
  5. webbee-0.3.34/src/webbee/clipboard_read.py +154 -0
  6. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/coding_context.py +13 -1
  7. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/home_view.py +3 -0
  8. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/watch.py +3 -1
  9. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/output_pane.py +7 -0
  10. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/repl.py +64 -0
  11. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/tools.py +6 -1
  12. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/tui.py +71 -2
  13. webbee-0.3.34/tests/test_clipboard_read.py +84 -0
  14. webbee-0.3.34/tests/test_coding_context.py +31 -0
  15. {webbee-0.3.32 → webbee-0.3.34}/tests/test_repl.py +26 -0
  16. {webbee-0.3.32 → webbee-0.3.34}/tests/test_tools.py +16 -0
  17. {webbee-0.3.32 → webbee-0.3.34}/tests/test_tui.py +38 -0
  18. webbee-0.3.32/src/webbee/__init__.py +0 -1
  19. {webbee-0.3.32 → webbee-0.3.34}/.github/workflows/publish.yml +0 -0
  20. {webbee-0.3.32 → webbee-0.3.34}/.gitignore +0 -0
  21. {webbee-0.3.32 → webbee-0.3.34}/LICENSE +0 -0
  22. {webbee-0.3.32 → webbee-0.3.34}/README.md +0 -0
  23. {webbee-0.3.32 → webbee-0.3.34}/docs/superpowers/plans/2026-07-21-interactive-home-dashboard.md +0 -0
  24. {webbee-0.3.32 → webbee-0.3.34}/install.sh +0 -0
  25. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/account.py +0 -0
  26. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/active_sessions.py +0 -0
  27. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/banner_art.py +0 -0
  28. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/boot.py +0 -0
  29. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/checkpoints.py +0 -0
  30. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/cli.py +0 -0
  31. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/clipboard.py +0 -0
  32. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/commands.py +0 -0
  33. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/config.py +0 -0
  34. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/consent.py +0 -0
  35. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/details.py +0 -0
  36. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/events.py +0 -0
  37. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/frames.py +0 -0
  38. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/home.py +0 -0
  39. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/http.py +0 -0
  40. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/instance_lock.py +0 -0
  41. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/__init__.py +0 -0
  42. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/chunker.py +0 -0
  43. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/embed.py +0 -0
  44. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/graph.py +0 -0
  45. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/indexer.py +0 -0
  46. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/models.py +0 -0
  47. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/query.py +0 -0
  48. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/service.py +0 -0
  49. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/store.py +0 -0
  50. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/intel/vectors.py +0 -0
  51. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/mode_store.py +0 -0
  52. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/newtab_mode.py +0 -0
  53. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/queue_panel.py +0 -0
  54. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/reflow.py +0 -0
  55. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/remote.py +0 -0
  56. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/render.py +0 -0
  57. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/repo.py +0 -0
  58. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/selection.py +0 -0
  59. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/session.py +0 -0
  60. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/sessions.py +0 -0
  61. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/sizing.py +0 -0
  62. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/slots.py +0 -0
  63. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/steer.py +0 -0
  64. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/stream.py +0 -0
  65. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/tabs.py +0 -0
  66. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/thread.py +0 -0
  67. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/todo_panel.py +0 -0
  68. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/tokens.py +0 -0
  69. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/update.py +0 -0
  70. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/urlopen.py +0 -0
  71. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/wallet.py +0 -0
  72. {webbee-0.3.32 → webbee-0.3.34}/src/webbee/worktrees.py +0 -0
  73. {webbee-0.3.32 → webbee-0.3.34}/tests/__init__.py +0 -0
  74. {webbee-0.3.32 → webbee-0.3.34}/tests/conftest.py +0 -0
  75. {webbee-0.3.32 → webbee-0.3.34}/tests/test_account.py +0 -0
  76. {webbee-0.3.32 → webbee-0.3.34}/tests/test_active_sessions.py +0 -0
  77. {webbee-0.3.32 → webbee-0.3.34}/tests/test_checkpoints.py +0 -0
  78. {webbee-0.3.32 → webbee-0.3.34}/tests/test_cli.py +0 -0
  79. {webbee-0.3.32 → webbee-0.3.34}/tests/test_clipboard.py +0 -0
  80. {webbee-0.3.32 → webbee-0.3.34}/tests/test_commands.py +0 -0
  81. {webbee-0.3.32 → webbee-0.3.34}/tests/test_config.py +0 -0
  82. {webbee-0.3.32 → webbee-0.3.34}/tests/test_cpc_contract_stable.py +0 -0
  83. {webbee-0.3.32 → webbee-0.3.34}/tests/test_details.py +0 -0
  84. {webbee-0.3.32 → webbee-0.3.34}/tests/test_events.py +0 -0
  85. {webbee-0.3.32 → webbee-0.3.34}/tests/test_freeze_fix.py +0 -0
  86. {webbee-0.3.32 → webbee-0.3.34}/tests/test_home.py +0 -0
  87. {webbee-0.3.32 → webbee-0.3.34}/tests/test_home_view.py +0 -0
  88. {webbee-0.3.32 → webbee-0.3.34}/tests/test_instance_lock.py +0 -0
  89. {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_chunker.py +0 -0
  90. {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_embed.py +0 -0
  91. {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_graph.py +0 -0
  92. {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_indexer.py +0 -0
  93. {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_no_numpy.py +0 -0
  94. {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_query.py +0 -0
  95. {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_service.py +0 -0
  96. {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_store.py +0 -0
  97. {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_vectors.py +0 -0
  98. {webbee-0.3.32 → webbee-0.3.34}/tests/test_intel_watch.py +0 -0
  99. {webbee-0.3.32 → webbee-0.3.34}/tests/test_marathon.py +0 -0
  100. {webbee-0.3.32 → webbee-0.3.34}/tests/test_mode_store.py +0 -0
  101. {webbee-0.3.32 → webbee-0.3.34}/tests/test_newtab_mode.py +0 -0
  102. {webbee-0.3.32 → webbee-0.3.34}/tests/test_packaging.py +0 -0
  103. {webbee-0.3.32 → webbee-0.3.34}/tests/test_reflow.py +0 -0
  104. {webbee-0.3.32 → webbee-0.3.34}/tests/test_render.py +0 -0
  105. {webbee-0.3.32 → webbee-0.3.34}/tests/test_repo.py +0 -0
  106. {webbee-0.3.32 → webbee-0.3.34}/tests/test_session.py +0 -0
  107. {webbee-0.3.32 → webbee-0.3.34}/tests/test_sessions.py +0 -0
  108. {webbee-0.3.32 → webbee-0.3.34}/tests/test_sizing.py +0 -0
  109. {webbee-0.3.32 → webbee-0.3.34}/tests/test_slots.py +0 -0
  110. {webbee-0.3.32 → webbee-0.3.34}/tests/test_steer.py +0 -0
  111. {webbee-0.3.32 → webbee-0.3.34}/tests/test_stream.py +0 -0
  112. {webbee-0.3.32 → webbee-0.3.34}/tests/test_tabs.py +0 -0
  113. {webbee-0.3.32 → webbee-0.3.34}/tests/test_thread.py +0 -0
  114. {webbee-0.3.32 → webbee-0.3.34}/tests/test_todo_panel.py +0 -0
  115. {webbee-0.3.32 → webbee-0.3.34}/tests/test_tokens.py +0 -0
  116. {webbee-0.3.32 → webbee-0.3.34}/tests/test_tui_hardening.py +0 -0
  117. {webbee-0.3.32 → webbee-0.3.34}/tests/test_update.py +0 -0
  118. {webbee-0.3.32 → webbee-0.3.34}/tests/test_urlopen.py +0 -0
  119. {webbee-0.3.32 → webbee-0.3.34}/tests/test_version.py +0 -0
  120. {webbee-0.3.32 → webbee-0.3.34}/tests/test_wallet.py +0 -0
  121. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.32
3
+ Version: 0.3.34
4
4
  Summary: Webbee 🐝 — the Imperal Cloud coding agent in your terminal
5
5
  Project-URL: Homepage, https://imperal.io
6
6
  Project-URL: Documentation, https://docs.imperal.io
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.3.32"
3
+ version = "0.3.34"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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 if d != ".git" and not d.startswith(".")]
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
- return "/.git/" in p or "/node_modules/" in p
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, _dn, fns in os.walk(base):
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(0.25)
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
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