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