webbee 0.1.3__tar.gz → 0.1.4__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 (38) hide show
  1. {webbee-0.1.3 → webbee-0.1.4}/CHANGELOG.md +6 -0
  2. {webbee-0.1.3 → webbee-0.1.4}/PKG-INFO +1 -1
  3. {webbee-0.1.3 → webbee-0.1.4}/pyproject.toml +1 -1
  4. webbee-0.1.4/src/webbee/__init__.py +1 -0
  5. {webbee-0.1.3 → webbee-0.1.4}/src/webbee/tui.py +85 -2
  6. {webbee-0.1.3 → webbee-0.1.4}/tests/test_tui.py +36 -0
  7. webbee-0.1.3/src/webbee/__init__.py +0 -1
  8. {webbee-0.1.3 → webbee-0.1.4}/.github/workflows/publish.yml +0 -0
  9. {webbee-0.1.3 → webbee-0.1.4}/.gitignore +0 -0
  10. {webbee-0.1.3 → webbee-0.1.4}/LICENSE +0 -0
  11. {webbee-0.1.3 → webbee-0.1.4}/README.md +0 -0
  12. {webbee-0.1.3 → webbee-0.1.4}/install.sh +0 -0
  13. {webbee-0.1.3 → webbee-0.1.4}/src/webbee/account.py +0 -0
  14. {webbee-0.1.3 → webbee-0.1.4}/src/webbee/banner_art.py +0 -0
  15. {webbee-0.1.3 → webbee-0.1.4}/src/webbee/cli.py +0 -0
  16. {webbee-0.1.3 → webbee-0.1.4}/src/webbee/commands.py +0 -0
  17. {webbee-0.1.3 → webbee-0.1.4}/src/webbee/config.py +0 -0
  18. {webbee-0.1.3 → webbee-0.1.4}/src/webbee/events.py +0 -0
  19. {webbee-0.1.3 → webbee-0.1.4}/src/webbee/render.py +0 -0
  20. {webbee-0.1.3 → webbee-0.1.4}/src/webbee/repl.py +0 -0
  21. {webbee-0.1.3 → webbee-0.1.4}/src/webbee/session.py +0 -0
  22. {webbee-0.1.3 → webbee-0.1.4}/src/webbee/sessions.py +0 -0
  23. {webbee-0.1.3 → webbee-0.1.4}/src/webbee/tools.py +0 -0
  24. {webbee-0.1.3 → webbee-0.1.4}/src/webbee/update.py +0 -0
  25. {webbee-0.1.3 → webbee-0.1.4}/tests/__init__.py +0 -0
  26. {webbee-0.1.3 → webbee-0.1.4}/tests/test_account.py +0 -0
  27. {webbee-0.1.3 → webbee-0.1.4}/tests/test_cli.py +0 -0
  28. {webbee-0.1.3 → webbee-0.1.4}/tests/test_commands.py +0 -0
  29. {webbee-0.1.3 → webbee-0.1.4}/tests/test_config.py +0 -0
  30. {webbee-0.1.3 → webbee-0.1.4}/tests/test_events.py +0 -0
  31. {webbee-0.1.3 → webbee-0.1.4}/tests/test_packaging.py +0 -0
  32. {webbee-0.1.3 → webbee-0.1.4}/tests/test_render.py +0 -0
  33. {webbee-0.1.3 → webbee-0.1.4}/tests/test_repl.py +0 -0
  34. {webbee-0.1.3 → webbee-0.1.4}/tests/test_session.py +0 -0
  35. {webbee-0.1.3 → webbee-0.1.4}/tests/test_sessions.py +0 -0
  36. {webbee-0.1.3 → webbee-0.1.4}/tests/test_tools.py +0 -0
  37. {webbee-0.1.3 → webbee-0.1.4}/tests/test_update.py +0 -0
  38. {webbee-0.1.3 → webbee-0.1.4}/tests/test_version.py +0 -0
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4
4
+
5
+ - **Copy on select.** Drag to select text in the dock and it's copied to your
6
+ clipboard automatically (via OSC 52 — works locally and over SSH), with a
7
+ brief “✓ copied” confirmation. Mouse-wheel scrolling still works.
8
+
3
9
  ## 0.1.3
4
10
 
5
11
  - **Sessions & security.** `/sessions` lists everywhere your Imperal account is
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.1.3
3
+ Version: 0.1.4
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.1.3"
3
+ version = "0.1.4"
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.1.4"
@@ -60,6 +60,7 @@ class OutputPane:
60
60
  from prompt_toolkit.formatted_text import ANSI
61
61
  from prompt_toolkit.layout.containers import Window
62
62
  from prompt_toolkit.layout.controls import FormattedTextControl
63
+ from prompt_toolkit.mouse_events import MouseEventType, MouseButton
63
64
  from rich.console import Console
64
65
 
65
66
  self._io = io.StringIO()
@@ -67,7 +68,35 @@ class OutputPane:
67
68
  color_system="truecolor", width=width, highlight=False)
68
69
  self._ANSI = ANSI
69
70
  self._cache = (None, None) # (text, ANSI) memo — bounds re-parse cost
70
- self.control = FormattedTextControl(
71
+ self.copy_flash = "" # transient toolbar note after a copy
72
+ self._flash_until = 0.0
73
+ pane = self
74
+
75
+ # Copy-on-select: left-drag over the pane copies the covered text to the
76
+ # system clipboard via OSC 52 (works locally + over SSH). The Window
77
+ # gives us CONTENT coordinates (Point(x=col, y=row)); with wrap_lines
78
+ # False, row = content line, col = char index. SCROLL events return
79
+ # NotImplemented so the Window keeps handling the wheel.
80
+ class _SelectControl(FormattedTextControl):
81
+ def __init__(self, **kw):
82
+ super().__init__(**kw)
83
+ self._down = None
84
+
85
+ def mouse_handler(self, ev):
86
+ et = ev.event_type
87
+ if et == MouseEventType.MOUSE_DOWN and ev.button == MouseButton.LEFT:
88
+ self._down = ev.position
89
+ return None
90
+ if et == MouseEventType.MOUSE_MOVE:
91
+ return None if self._down is not None else NotImplemented
92
+ if et == MouseEventType.MOUSE_UP:
93
+ down, self._down = self._down, None
94
+ if down is not None and (down.x, down.y) != (ev.position.x, ev.position.y):
95
+ pane._copy_selection(down, ev.position) # real drag, not a click
96
+ return None
97
+ return NotImplemented # SCROLL_UP/DOWN → Window scrolls
98
+
99
+ self.control = _SelectControl(
71
100
  text=self._formatted, focusable=False, show_cursor=False,
72
101
  get_cursor_position=lambda: Point(0, self.window.vertical_scroll))
73
102
  self.window = Window(content=self.control, wrap_lines=False,
@@ -109,6 +138,57 @@ class OutputPane:
109
138
  the conversation survives leaving the alternate screen."""
110
139
  return self._io.getvalue()
111
140
 
141
+ # ---- copy-on-select --------------------------------------------------
142
+ def _selected_text(self, start, end) -> str:
143
+ """Plain text (ANSI stripped) covered by a drag from `start` to `end`
144
+ (both Points with content col=x / line=y). wrap_lines is False so a
145
+ content line maps 1:1 to a `\\n`-split line and col = char index."""
146
+ import re
147
+ plain = re.sub(r"\x1b\[[0-9;]*m", "", self._io.getvalue())
148
+ lines = plain.split("\n")
149
+ p1, p2 = (start.y, start.x), (end.y, end.x)
150
+ if p1 > p2:
151
+ p1, p2 = p2, p1
152
+ (y1, x1), (y2, x2) = p1, p2
153
+ n = len(lines)
154
+ if not (0 <= y1 < n):
155
+ return ""
156
+ y2 = min(y2, n - 1)
157
+ if y1 == y2:
158
+ return lines[y1][x1:x2 + 1]
159
+ out = [lines[y1][x1:]]
160
+ out.extend(lines[y1 + 1:y2])
161
+ out.append(lines[y2][:x2 + 1])
162
+ return "\n".join(out)
163
+
164
+ def _osc52_copy(self, text: str) -> None:
165
+ import base64
166
+ try:
167
+ from prompt_toolkit.application import get_app_or_none
168
+ app = get_app_or_none()
169
+ if app is None:
170
+ return
171
+ b64 = base64.b64encode(text.encode("utf-8", "replace")).decode("ascii")
172
+ app.output.write_raw("\x1b]52;c;" + b64 + "\x07") # OSC 52 — set clipboard
173
+ app.output.flush()
174
+ except Exception:
175
+ pass
176
+
177
+ def _copy_selection(self, start, end) -> None:
178
+ import time as _t
179
+ text = self._selected_text(start, end)
180
+ if not text.strip():
181
+ return
182
+ self._osc52_copy(text)
183
+ self.copy_flash = f"✓ copied {len(text)} char{'s' if len(text) != 1 else ''}"
184
+ self._flash_until = _t.monotonic() + 1.8
185
+ self.notify()
186
+
187
+ def flash(self) -> str:
188
+ """The transient 'copied' note, while still fresh (else empty)."""
189
+ import time as _t
190
+ return self.copy_flash if _t.monotonic() < self._flash_until else ""
191
+
112
192
 
113
193
  async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
114
194
  is_busy, consent_pending, resolve_consent) -> bool:
@@ -177,6 +257,9 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
177
257
  pane.window.vertical_scroll += 5
178
258
 
179
259
  def _toolbar():
260
+ f = pane.flash()
261
+ if f:
262
+ return [("class:tb.working", " " + f)] # transient copy confirmation
180
263
  st = status()
181
264
  return build_toolbar(mode_getter(), st["tokens"], st["cost"], busy=st["busy"],
182
265
  current=st["current"], elapsed=st["elapsed"],
@@ -206,7 +289,7 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
206
289
  # animate the spinner + tick the elapsed clock while a turn runs
207
290
  while True:
208
291
  await asyncio.sleep(0.25)
209
- if is_busy():
292
+ if is_busy() or pane.flash():
210
293
  app.invalidate()
211
294
 
212
295
  tick = asyncio.ensure_future(_ticker())
@@ -70,3 +70,39 @@ def test_output_pane_captures_colored_text():
70
70
  out = pane.dump()
71
71
  assert "Error" in out and "ok" in out
72
72
  assert "\x1b[" in out # ANSI colour escapes preserved for the pane
73
+
74
+
75
+ # ── copy-on-select (drag → OSC 52) ────────────────────────────────────────────
76
+
77
+ def test_selected_text_single_line():
78
+ from webbee.tui import OutputPane
79
+ from prompt_toolkit.data_structures import Point
80
+ pane = OutputPane(width=80)
81
+ pane.console.print("hello world")
82
+ assert pane._selected_text(Point(6, 0), Point(10, 0)) == "world"
83
+
84
+
85
+ def test_selected_text_multi_line_strips_ansi():
86
+ from webbee.tui import OutputPane
87
+ from prompt_toolkit.data_structures import Point
88
+ pane = OutputPane(width=80)
89
+ pane.console.print("abcdef")
90
+ pane.console.print("[bold]ghijkl[/]") # coloured — must be stripped
91
+ pane.console.print("mnopqr")
92
+ assert pane._selected_text(Point(3, 0), Point(2, 2)) == "def\nghijkl\nmno"
93
+
94
+
95
+ def test_selected_text_reversed_order_normalizes():
96
+ from webbee.tui import OutputPane
97
+ from prompt_toolkit.data_structures import Point
98
+ pane = OutputPane(width=80)
99
+ pane.console.print("hello")
100
+ assert pane._selected_text(Point(4, 0), Point(0, 0)) == "hello"
101
+
102
+
103
+ def test_copy_flash_expires():
104
+ from webbee.tui import OutputPane
105
+ pane = OutputPane(width=80)
106
+ pane.copy_flash = "✓ copied 5 chars"
107
+ pane._flash_until = 0.0 # already in the past
108
+ assert pane.flash() == ""
@@ -1 +0,0 @@
1
- __version__ = "0.1.3"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes