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.
- {webbee-0.1.3 → webbee-0.1.4}/CHANGELOG.md +6 -0
- {webbee-0.1.3 → webbee-0.1.4}/PKG-INFO +1 -1
- {webbee-0.1.3 → webbee-0.1.4}/pyproject.toml +1 -1
- webbee-0.1.4/src/webbee/__init__.py +1 -0
- {webbee-0.1.3 → webbee-0.1.4}/src/webbee/tui.py +85 -2
- {webbee-0.1.3 → webbee-0.1.4}/tests/test_tui.py +36 -0
- webbee-0.1.3/src/webbee/__init__.py +0 -1
- {webbee-0.1.3 → webbee-0.1.4}/.github/workflows/publish.yml +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/.gitignore +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/LICENSE +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/README.md +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/install.sh +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/src/webbee/account.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/src/webbee/banner_art.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/src/webbee/cli.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/src/webbee/commands.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/src/webbee/config.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/src/webbee/events.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/src/webbee/render.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/src/webbee/repl.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/src/webbee/session.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/src/webbee/sessions.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/src/webbee/tools.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/src/webbee/update.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/tests/__init__.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/tests/test_account.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/tests/test_cli.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/tests/test_commands.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/tests/test_config.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/tests/test_events.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/tests/test_packaging.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/tests/test_render.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/tests/test_repl.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/tests/test_session.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/tests/test_sessions.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/tests/test_tools.py +0 -0
- {webbee-0.1.3 → webbee-0.1.4}/tests/test_update.py +0 -0
- {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
|
|
@@ -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.
|
|
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
|
|
File without changes
|