webbee 0.1.2__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 (39) hide show
  1. webbee-0.1.4/.github/workflows/publish.yml +47 -0
  2. {webbee-0.1.2 → webbee-0.1.4}/CHANGELOG.md +14 -0
  3. {webbee-0.1.2 → webbee-0.1.4}/PKG-INFO +6 -6
  4. {webbee-0.1.2 → webbee-0.1.4}/README.md +5 -5
  5. {webbee-0.1.2 → webbee-0.1.4}/pyproject.toml +1 -1
  6. webbee-0.1.4/src/webbee/__init__.py +1 -0
  7. {webbee-0.1.2 → webbee-0.1.4}/src/webbee/commands.py +11 -0
  8. {webbee-0.1.2 → webbee-0.1.4}/src/webbee/render.py +25 -0
  9. {webbee-0.1.2 → webbee-0.1.4}/src/webbee/repl.py +29 -1
  10. webbee-0.1.4/src/webbee/sessions.py +48 -0
  11. {webbee-0.1.2 → webbee-0.1.4}/src/webbee/tui.py +85 -2
  12. {webbee-0.1.2 → webbee-0.1.4}/tests/test_commands.py +10 -2
  13. {webbee-0.1.2 → webbee-0.1.4}/tests/test_render.py +20 -0
  14. {webbee-0.1.2 → webbee-0.1.4}/tests/test_repl.py +45 -1
  15. webbee-0.1.4/tests/test_sessions.py +51 -0
  16. {webbee-0.1.2 → webbee-0.1.4}/tests/test_tui.py +36 -0
  17. webbee-0.1.2/.github/workflows/publish.yml +0 -28
  18. webbee-0.1.2/src/webbee/__init__.py +0 -1
  19. {webbee-0.1.2 → webbee-0.1.4}/.gitignore +0 -0
  20. {webbee-0.1.2 → webbee-0.1.4}/LICENSE +0 -0
  21. {webbee-0.1.2 → webbee-0.1.4}/install.sh +0 -0
  22. {webbee-0.1.2 → webbee-0.1.4}/src/webbee/account.py +0 -0
  23. {webbee-0.1.2 → webbee-0.1.4}/src/webbee/banner_art.py +0 -0
  24. {webbee-0.1.2 → webbee-0.1.4}/src/webbee/cli.py +0 -0
  25. {webbee-0.1.2 → webbee-0.1.4}/src/webbee/config.py +0 -0
  26. {webbee-0.1.2 → webbee-0.1.4}/src/webbee/events.py +0 -0
  27. {webbee-0.1.2 → webbee-0.1.4}/src/webbee/session.py +0 -0
  28. {webbee-0.1.2 → webbee-0.1.4}/src/webbee/tools.py +0 -0
  29. {webbee-0.1.2 → webbee-0.1.4}/src/webbee/update.py +0 -0
  30. {webbee-0.1.2 → webbee-0.1.4}/tests/__init__.py +0 -0
  31. {webbee-0.1.2 → webbee-0.1.4}/tests/test_account.py +0 -0
  32. {webbee-0.1.2 → webbee-0.1.4}/tests/test_cli.py +0 -0
  33. {webbee-0.1.2 → webbee-0.1.4}/tests/test_config.py +0 -0
  34. {webbee-0.1.2 → webbee-0.1.4}/tests/test_events.py +0 -0
  35. {webbee-0.1.2 → webbee-0.1.4}/tests/test_packaging.py +0 -0
  36. {webbee-0.1.2 → webbee-0.1.4}/tests/test_session.py +0 -0
  37. {webbee-0.1.2 → webbee-0.1.4}/tests/test_tools.py +0 -0
  38. {webbee-0.1.2 → webbee-0.1.4}/tests/test_update.py +0 -0
  39. {webbee-0.1.2 → webbee-0.1.4}/tests/test_version.py +0 -0
@@ -0,0 +1,47 @@
1
+ name: publish
2
+
3
+ # Publish to PyPI on a version tag (e.g. `v0.1.0`), matching the Imperal
4
+ # convention (imperal-mcp). Auth uses the account API token stored as the
5
+ # GitHub Actions secret `PYPI_API_TOKEN` — add it to this repo, or set an
6
+ # `imperalcloud` org-level secret so every package inherits it. The token is
7
+ # NEVER stored in the repo/files; only in the encrypted Actions secret.
8
+
9
+ on:
10
+ push:
11
+ tags: ["v*"]
12
+
13
+ jobs:
14
+ publish:
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ contents: write # create the GitHub Release for the tag
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.12"
23
+ - name: Build sdist + wheel
24
+ run: |
25
+ python -m pip install --upgrade build
26
+ python -m build
27
+ - name: Publish to PyPI
28
+ uses: pypa/gh-action-pypi-publish@release/v1
29
+ with:
30
+ password: ${{ secrets.PYPI_API_TOKEN }}
31
+ # Mirror the PyPI release as a GitHub Release (so the repo's Releases
32
+ # sidebar tracks the latest version) with notes from CHANGELOG.md.
33
+ - name: Create GitHub Release
34
+ env:
35
+ GH_TOKEN: ${{ github.token }}
36
+ run: |
37
+ VERSION="${GITHUB_REF_NAME#v}"
38
+ python - "$VERSION" > notes.md <<'PY'
39
+ import sys, re, pathlib
40
+ v = sys.argv[1]
41
+ text = pathlib.Path("CHANGELOG.md").read_text()
42
+ m = re.search(rf"^## {re.escape(v)}\b.*?(?=^## |\Z)", text, re.S | re.M)
43
+ sys.stdout.write(m.group(0).strip() if m else
44
+ f"Published to PyPI: https://pypi.org/project/webbee/{v}/")
45
+ PY
46
+ gh release create "$GITHUB_REF_NAME" \
47
+ --title "webbee ${GITHUB_REF_NAME}" --latest --notes-file notes.md
@@ -1,5 +1,19 @@
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
+
9
+ ## 0.1.3
10
+
11
+ - **Sessions & security.** `/sessions` lists everywhere your Imperal account is
12
+ signed in — terminal (webbee), API, and web — with the current terminal
13
+ marked. `/sessions revoke <#>` signs out one; `/logout-others` signs out
14
+ every session except this one. Manage the same sessions from the panel
15
+ (Settings → Security). Backed by a single gateway-owned session store.
16
+
3
17
  ## 0.1.2
4
18
 
5
19
  - **Device-code login (RFC 8628).** `/login` and `webbee login` now use the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.1.2
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
@@ -32,10 +32,10 @@ Description-Content-Type: text/markdown
32
32
 
33
33
  # Webbee 🐝 — the coding agent in your terminal
34
34
 
35
- [![PyPI](https://img.shields.io/pypi/v/webbee.svg)](https://pypi.org/project/webbee/)
36
- [![Python](https://img.shields.io/pypi/pyversions/webbee.svg)](https://pypi.org/project/webbee/)
37
- [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
38
- [![Docs](https://img.shields.io/badge/docs-imperal.io-00afd7.svg)](https://docs.imperal.io)
35
+ [![PyPI](https://img.shields.io/pypi/v/webbee)](https://pypi.org/project/webbee/)
36
+ [![Python](https://img.shields.io/pypi/pyversions/webbee)](https://pypi.org/project/webbee/)
37
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
38
+ [![Docs](https://img.shields.io/badge/docs-imperal.io-00afd7)](https://docs.imperal.io)
39
39
 
40
40
  Webbee is the [Imperal Cloud](https://imperal.io) coding agent, in your terminal. It reads, writes, and runs code in your working directory — while the brain runs in the cloud on **ICNLI**, the open protocol behind Webbee. No model keys on your machine. Swap the model underneath and it behaves the same, because the safety was never in the model.
41
41
 
@@ -61,7 +61,7 @@ python3 -m venv .venv && . .venv/bin/activate && pip install webbee
61
61
 
62
62
  ```sh
63
63
  webbee # start the agent in the current directory
64
- webbee login # sign in to your Imperal account (opens the browser)
64
+ webbee login # sign in shows a code + URL to open in any browser
65
65
  ```
66
66
 
67
67
  Type in plain English. Webbee reads your files, runs commands, and reaches your connected Imperal apps — mail, notes, tasks, and more — to get the job done. `/help` lists the commands: `/login` `/logout` `/mode` `/cost` `/status` `/clear` `/exit`.
@@ -1,9 +1,9 @@
1
1
  # Webbee 🐝 — the coding agent in your terminal
2
2
 
3
- [![PyPI](https://img.shields.io/pypi/v/webbee.svg)](https://pypi.org/project/webbee/)
4
- [![Python](https://img.shields.io/pypi/pyversions/webbee.svg)](https://pypi.org/project/webbee/)
5
- [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
- [![Docs](https://img.shields.io/badge/docs-imperal.io-00afd7.svg)](https://docs.imperal.io)
3
+ [![PyPI](https://img.shields.io/pypi/v/webbee)](https://pypi.org/project/webbee/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/webbee)](https://pypi.org/project/webbee/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
6
+ [![Docs](https://img.shields.io/badge/docs-imperal.io-00afd7)](https://docs.imperal.io)
7
7
 
8
8
  Webbee is the [Imperal Cloud](https://imperal.io) coding agent, in your terminal. It reads, writes, and runs code in your working directory — while the brain runs in the cloud on **ICNLI**, the open protocol behind Webbee. No model keys on your machine. Swap the model underneath and it behaves the same, because the safety was never in the model.
9
9
 
@@ -29,7 +29,7 @@ python3 -m venv .venv && . .venv/bin/activate && pip install webbee
29
29
 
30
30
  ```sh
31
31
  webbee # start the agent in the current directory
32
- webbee login # sign in to your Imperal account (opens the browser)
32
+ webbee login # sign in shows a code + URL to open in any browser
33
33
  ```
34
34
 
35
35
  Type in plain English. Webbee reads your files, runs commands, and reaches your connected Imperal apps — mail, notes, tasks, and more — to get the job done. `/help` lists the commands: `/login` `/logout` `/mode` `/cost` `/status` `/clear` `/exit`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.1.2"
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"
@@ -10,6 +10,9 @@ _HELP = """Commands:
10
10
  /mode [default|plan|autopilot] consent mode (no arg — show current)
11
11
  /cost (=/usage) tokens + $ cost this session
12
12
  /status cwd · git · surface · tokens · version
13
+ /sessions list active sessions (this + other devices)
14
+ /sessions revoke <#> revoke a session by its number
15
+ /logout-others sign out every session except this one
13
16
  /exit (=/quit) quit"""
14
17
 
15
18
 
@@ -32,6 +35,7 @@ class SlashResult:
32
35
  message: str = ""
33
36
  action: str = ""
34
37
  new_mode: "str | None" = None
38
+ arg: str = ""
35
39
 
36
40
 
37
41
  def dispatch(line: str, ctx: CommandContext) -> SlashResult:
@@ -64,6 +68,13 @@ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
64
68
  f"cwd: {ctx.workspace} git: {ctx.git_branch}\n"
65
69
  f"tokens: {ctx.session_tokens} (~${ctx.session_cost:.4f}) webbee v{ctx.version}")
66
70
  return SlashResult(handled=True, action="status", message=msg)
71
+ if cmd == "/sessions":
72
+ if args and args[0].lower() == "revoke":
73
+ return SlashResult(handled=True, action="sessions_revoke",
74
+ arg=(args[1] if len(args) > 1 else ""))
75
+ return SlashResult(handled=True, action="sessions")
76
+ if cmd == "/logout-others":
77
+ return SlashResult(handled=True, action="logout_others")
67
78
  if cmd == "/mode":
68
79
  if not args:
69
80
  return SlashResult(handled=True, action="mode", new_mode=None,
@@ -264,6 +264,31 @@ class RichSink:
264
264
  self.console.print(Panel(body, title="🐝 Connect this terminal", border_style=_BEE))
265
265
  self._nudge()
266
266
 
267
+ def sessions_table(self, sessions) -> None:
268
+ """Active sessions as a compact table (English-only): #, session, IP,
269
+ last-seen, and a bee-yellow 'this device' marker for the current one."""
270
+ from rich.table import Table
271
+ self.console.print(Text(" Active sessions", style=f"bold {_BEE}"))
272
+ if not sessions:
273
+ self.console.print(Text(" (none)", style="dim"))
274
+ self._nudge()
275
+ return
276
+ t = Table(show_header=True, header_style="dim", box=None, padding=(0, 3, 0, 0))
277
+ t.add_column("#", style="dim", justify="right")
278
+ t.add_column("session")
279
+ t.add_column("ip", style="dim")
280
+ t.add_column("last seen", style="dim")
281
+ t.add_column("")
282
+ for i, s in enumerate(sessions, 1):
283
+ label = str(s.get("label") or s.get("surface") or "?")
284
+ ip = str(s.get("ip_address") or "-")
285
+ seen = str(s.get("last_seen_at") or "")[:16].replace("T", " ")
286
+ here = Text("this device", style=f"bold {_BEE}") if s.get("current") else Text("")
287
+ t.add_row(str(i), label, ip, seen, here)
288
+ self.console.print(t)
289
+ self.console.print(Text(" /sessions revoke <#> · /logout-others", style="dim"))
290
+ self._nudge()
291
+
267
292
  def progress(self, text: str) -> None:
268
293
  if text:
269
294
  self.console.print(Text(" " + text, style="dim italic"))
@@ -19,7 +19,8 @@ def _git_branch(workspace: str) -> str:
19
19
 
20
20
 
21
21
  async def run_repl(cfg, mode: str = "default", *, sink=None, read_line=input,
22
- agent_factory=None, auth=None, account_fetcher=None) -> None:
22
+ agent_factory=None, auth=None, account_fetcher=None,
23
+ sessions_client=None) -> None:
23
24
  """Interactive coding REPL. Production (a real tty, no injected sink) runs
24
25
  the persistent prompt_toolkit dock (`tui.run_session`): the bordered input
25
26
  box is pinned at the bottom, turn output scrolls above it (patch_stdout →
@@ -32,6 +33,8 @@ async def run_repl(cfg, mode: str = "default", *, sink=None, read_line=input,
32
33
  agent_factory = lambda c, tp, ws, m: AgentSession(c, tp, ws, m) # noqa: E731
33
34
  if account_fetcher is None:
34
35
  from webbee.account import fetch_account as account_fetcher
36
+ if sessions_client is None:
37
+ from webbee import sessions as sessions_client
35
38
 
36
39
  workspace = os.getcwd()
37
40
 
@@ -85,6 +88,31 @@ async def run_repl(cfg, mode: str = "default", *, sink=None, read_line=input,
85
88
  state["logged_in"] = False
86
89
  _sink.note("Signed out, local credentials removed.")
87
90
  return "continue"
91
+ if res.action == "sessions":
92
+ rows = await sessions_client.list_sessions(cfg, token_provider)
93
+ state["sessions"] = rows
94
+ _sink.sessions_table(rows)
95
+ return "continue"
96
+ if res.action == "sessions_revoke":
97
+ rows = state.get("sessions") or []
98
+ try:
99
+ idx = int(res.arg) - 1
100
+ except ValueError:
101
+ idx = -1
102
+ if idx < 0 or idx >= len(rows):
103
+ _sink.note("Usage: /sessions revoke <#> — run /sessions first to see the list.")
104
+ return "continue"
105
+ s = rows[idx]
106
+ if s.get("current"):
107
+ _sink.note("That's this terminal — use /logout to sign out here.")
108
+ return "continue"
109
+ ok = await sessions_client.revoke_session(cfg, token_provider, s["session_id"])
110
+ _sink.note(f"Revoked {s.get('label') or s.get('surface')}." if ok else "Failed to revoke session.")
111
+ return "continue"
112
+ if res.action == "logout_others":
113
+ n = await sessions_client.revoke_others(cfg, token_provider)
114
+ _sink.note(f"Signed out {n} other session(s)." if n >= 0 else "Failed to sign out other sessions.")
115
+ return "continue"
88
116
  if res.action == "clear":
89
117
  _sink.clear()
90
118
  _sink.note(res.message)
@@ -0,0 +1,48 @@
1
+ """Sessions client — list/revoke the user's Imperal Cloud sessions from the
2
+ terminal. Talks to the auth gateway with the user's access token (Bearer), so
3
+ the gateway marks THIS terminal as the current session via its `sid` claim.
4
+ Best-effort: network errors never raise — they return empty/False so the REPL
5
+ can note the failure instead of crashing."""
6
+ from __future__ import annotations
7
+
8
+
9
+ async def _get(cfg, token_provider, path: str) -> dict:
10
+ import httpx
11
+ token = await token_provider()
12
+ async with httpx.AsyncClient(base_url=cfg.api_url, timeout=10) as c:
13
+ r = await c.get(path, headers={"Authorization": f"Bearer {token}"})
14
+ r.raise_for_status()
15
+ return r.json()
16
+
17
+
18
+ async def _post(cfg, token_provider, path: str) -> dict:
19
+ import httpx
20
+ token = await token_provider()
21
+ async with httpx.AsyncClient(base_url=cfg.api_url, timeout=10) as c:
22
+ r = await c.post(path, headers={"Authorization": f"Bearer {token}"})
23
+ r.raise_for_status()
24
+ return r.json() if r.content else {}
25
+
26
+
27
+ async def list_sessions(cfg, token_provider) -> list[dict]:
28
+ try:
29
+ return (await _get(cfg, token_provider, "/v1/auth/sessions")).get("sessions", [])
30
+ except Exception:
31
+ return []
32
+
33
+
34
+ async def revoke_session(cfg, token_provider, session_id: str) -> bool:
35
+ try:
36
+ await _post(cfg, token_provider, f"/v1/auth/sessions/{session_id}/revoke")
37
+ return True
38
+ except Exception:
39
+ return False
40
+
41
+
42
+ async def revoke_others(cfg, token_provider) -> int:
43
+ """Revoke every session except this terminal's. Returns the count, or -1 on
44
+ error (the gateway keeps the current session, identified by the JWT sid)."""
45
+ try:
46
+ return int((await _post(cfg, token_provider, "/v1/auth/sessions/revoke-others")).get("revoked", 0))
47
+ except Exception:
48
+ return -1
@@ -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())
@@ -26,13 +26,13 @@ def test_exit_and_quit():
26
26
  def test_help_lists_commands():
27
27
  r = dispatch("/help", _ctx())
28
28
  assert r.handled and r.action == "help"
29
- for c in ("/login", "/logout", "/clear", "/mode", "/cost", "/status", "/exit"):
29
+ for c in ("/login", "/logout", "/clear", "/mode", "/cost", "/status", "/sessions", "/logout-others", "/exit"):
30
30
  assert c in r.message
31
31
 
32
32
 
33
33
  def test_help_is_english():
34
34
  r = dispatch("/help", _ctx())
35
- for c in ("/login", "/logout", "/clear", "/mode", "/cost", "/status", "/exit"):
35
+ for c in ("/login", "/logout", "/clear", "/mode", "/cost", "/status", "/sessions", "/logout-others", "/exit"):
36
36
  assert c in r.message
37
37
  assert not NO_CYRILLIC.search(r.message)
38
38
 
@@ -98,6 +98,14 @@ def test_clear_login_logout_actions():
98
98
  assert dispatch("/logout", _ctx()).action == "logout"
99
99
 
100
100
 
101
+ def test_sessions_commands():
102
+ assert dispatch("/sessions", _ctx()).action == "sessions"
103
+ r = dispatch("/sessions revoke 2", _ctx())
104
+ assert r.action == "sessions_revoke" and r.arg == "2"
105
+ assert dispatch("/sessions revoke", _ctx()).action == "sessions_revoke" # no index -> arg ""
106
+ assert dispatch("/logout-others", _ctx()).action == "logout_others"
107
+
108
+
101
109
  def test_unknown_slash_is_handled_with_hint():
102
110
  r = dispatch("/frobnicate", _ctx())
103
111
  assert r.handled and "/help" in r.message
@@ -36,6 +36,26 @@ def test_login_prompt_shows_code_and_url():
36
36
  assert not NO_CYRILLIC.search(out) # English UI only
37
37
 
38
38
 
39
+ def test_sessions_table_renders():
40
+ s = _sink()
41
+ s.sessions_table([
42
+ {"session_id": "s1", "surface": "cli", "label": "Terminal (webbee)",
43
+ "ip_address": "1.2.3.4", "last_seen_at": "2026-07-04T00:00:00", "current": True},
44
+ {"session_id": "s2", "surface": "web", "label": "Web (Chrome)",
45
+ "ip_address": None, "last_seen_at": "2026-07-03T10:00:00", "current": False},
46
+ ])
47
+ out = s.console.export_text()
48
+ assert "Terminal (webbee)" in out and "Web (Chrome)" in out
49
+ assert "this device" in out and "1.2.3.4" in out
50
+ assert not NO_CYRILLIC.search(out)
51
+
52
+
53
+ def test_sessions_table_empty():
54
+ s = _sink()
55
+ s.sessions_table([])
56
+ assert "none" in s.console.export_text().lower()
57
+
58
+
39
59
  def test_ask_consent_relays_raw_input():
40
60
  console = Console(record=True, width=80)
41
61
  s = RichSink(console=console, live_enabled=False, input_fn=lambda p: " ага давай ", clock=lambda: 0.0)
@@ -19,6 +19,7 @@ class FakeSink:
19
19
  def abort(self): self.aborted = True
20
20
  def welcome(self, *a, **kw): ...
21
21
  def user_echo(self, text): self.echoed = getattr(self, "echoed", []) + [text]
22
+ def sessions_table(self, rows): self.session_tables = getattr(self, "session_tables", []) + [rows]
22
23
  # TurnSink no-ops
23
24
  def tool_start(self, *a): ...
24
25
  def tool_result(self, *a): ...
@@ -50,6 +51,19 @@ class FakeAuth:
50
51
  async def logout(self, cfg): self._in = False; self.logged_out = True
51
52
 
52
53
 
54
+ class FakeSessions:
55
+ def __init__(self, rows=None):
56
+ self.rows = rows if rows is not None else [
57
+ {"session_id": "s1", "surface": "cli", "label": "Terminal (webbee)", "current": True},
58
+ {"session_id": "s2", "surface": "web", "label": "Web (Chrome)", "current": False},
59
+ ]
60
+ self.revoked = []
61
+ self.others_called = False
62
+ async def list_sessions(self, cfg, tp): return self.rows
63
+ async def revoke_session(self, cfg, tp, sid): self.revoked.append(sid); return True
64
+ async def revoke_others(self, cfg, tp): self.others_called = True; return 2
65
+
66
+
53
67
  def _lines(*items):
54
68
  it = iter(items)
55
69
  def read(prompt=""):
@@ -71,7 +85,8 @@ def _run(**kw):
71
85
  agent = kw.pop("agent", FakeAgent())
72
86
  asyncio.run(run_repl(cfg, "default", sink=sink, agent_factory=lambda c, tp, ws, m: agent,
73
87
  read_line=kw.pop("read_line"), auth=kw.pop("auth", FakeAuth()),
74
- account_fetcher=kw.pop("account_fetcher", _fake_account_fetcher)))
88
+ account_fetcher=kw.pop("account_fetcher", _fake_account_fetcher),
89
+ sessions_client=kw.pop("sessions_client", FakeSessions())))
75
90
  return sink, agent
76
91
 
77
92
 
@@ -135,6 +150,35 @@ def test_login_uses_device_flow_and_renders_prompt():
135
150
  assert any("Signed in as u@imperal.io" in n for n in sink.notes)
136
151
 
137
152
 
153
+ def test_sessions_list_renders():
154
+ fs = FakeSessions()
155
+ sink, agent = _run(read_line=_lines("/sessions", "/exit"), sessions_client=fs)
156
+ assert getattr(sink, "session_tables", None) # sessions_table was rendered
157
+ assert sink.session_tables[0] == fs.rows
158
+
159
+
160
+ def test_sessions_revoke_by_index():
161
+ fs = FakeSessions()
162
+ sink, agent = _run(read_line=_lines("/sessions", "/sessions revoke 2", "/exit"), sessions_client=fs)
163
+ assert fs.revoked == ["s2"] # #2 = web (not current)
164
+ assert any("Revoked" in n for n in sink.notes)
165
+ assert not any(NO_CYRILLIC.search(n) for n in sink.notes)
166
+
167
+
168
+ def test_sessions_revoke_current_is_guarded():
169
+ fs = FakeSessions()
170
+ sink, agent = _run(read_line=_lines("/sessions", "/sessions revoke 1", "/exit"), sessions_client=fs)
171
+ assert fs.revoked == [] # #1 = current terminal -> guarded
172
+ assert any("this terminal" in n for n in sink.notes)
173
+
174
+
175
+ def test_logout_others_calls_client():
176
+ fs = FakeSessions()
177
+ sink, agent = _run(read_line=_lines("/logout-others", "/exit"), sessions_client=fs)
178
+ assert fs.others_called
179
+ assert any("other session" in n for n in sink.notes)
180
+
181
+
138
182
  def test_mode_command_switches_agent_mode():
139
183
  sink, agent = _run(read_line=_lines("/mode autopilot", "/exit"))
140
184
  assert agent.mode == "autopilot"
@@ -0,0 +1,51 @@
1
+ import asyncio
2
+
3
+ import webbee.sessions as S
4
+
5
+
6
+ def test_list_sessions_parses(monkeypatch):
7
+ async def fake_get(cfg, tp, path):
8
+ assert path == "/v1/auth/sessions"
9
+ return {"sessions": [{"session_id": "a", "surface": "cli", "current": True}]}
10
+ monkeypatch.setattr(S, "_get", fake_get)
11
+ out = asyncio.run(S.list_sessions(None, None))
12
+ assert out[0]["session_id"] == "a"
13
+
14
+
15
+ def test_list_sessions_network_error_returns_empty(monkeypatch):
16
+ async def boom(*a):
17
+ raise RuntimeError("net")
18
+ monkeypatch.setattr(S, "_get", boom)
19
+ assert asyncio.run(S.list_sessions(None, None)) == []
20
+
21
+
22
+ def test_revoke_session_posts_right_path(monkeypatch):
23
+ seen = {}
24
+ async def fake_post(cfg, tp, path):
25
+ seen["path"] = path
26
+ return {"status": "revoked"}
27
+ monkeypatch.setattr(S, "_post", fake_post)
28
+ assert asyncio.run(S.revoke_session(None, None, "sid1")) is True
29
+ assert seen["path"] == "/v1/auth/sessions/sid1/revoke"
30
+
31
+
32
+ def test_revoke_session_error_returns_false(monkeypatch):
33
+ async def boom(*a):
34
+ raise RuntimeError()
35
+ monkeypatch.setattr(S, "_post", boom)
36
+ assert asyncio.run(S.revoke_session(None, None, "x")) is False
37
+
38
+
39
+ def test_revoke_others_count(monkeypatch):
40
+ async def fake_post(cfg, tp, path):
41
+ assert path == "/v1/auth/sessions/revoke-others"
42
+ return {"revoked": 3}
43
+ monkeypatch.setattr(S, "_post", fake_post)
44
+ assert asyncio.run(S.revoke_others(None, None)) == 3
45
+
46
+
47
+ def test_revoke_others_error_returns_minus_one(monkeypatch):
48
+ async def boom(*a):
49
+ raise RuntimeError()
50
+ monkeypatch.setattr(S, "_post", boom)
51
+ assert asyncio.run(S.revoke_others(None, None)) == -1
@@ -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,28 +0,0 @@
1
- name: publish
2
-
3
- # Publish to PyPI on a version tag (e.g. `v0.1.0`), matching the Imperal
4
- # convention (imperal-mcp). Auth uses the account API token stored as the
5
- # GitHub Actions secret `PYPI_API_TOKEN` — add it to this repo, or set an
6
- # `imperalcloud` org-level secret so every package inherits it. The token is
7
- # NEVER stored in the repo/files; only in the encrypted Actions secret.
8
-
9
- on:
10
- push:
11
- tags: ["v*"]
12
-
13
- jobs:
14
- publish:
15
- runs-on: ubuntu-latest
16
- steps:
17
- - uses: actions/checkout@v4
18
- - uses: actions/setup-python@v5
19
- with:
20
- python-version: "3.12"
21
- - name: Build sdist + wheel
22
- run: |
23
- python -m pip install --upgrade build
24
- python -m build
25
- - name: Publish to PyPI
26
- uses: pypa/gh-action-pypi-publish@release/v1
27
- with:
28
- password: ${{ secrets.PYPI_API_TOKEN }}
@@ -1 +0,0 @@
1
- __version__ = "0.1.2"
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