webbee 0.5.2__tar.gz → 0.5.3__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.5.2 → webbee-0.5.3}/CHANGELOG.md +11 -0
- {webbee-0.5.2 → webbee-0.5.3}/PKG-INFO +1 -1
- {webbee-0.5.2 → webbee-0.5.3}/pyproject.toml +1 -1
- webbee-0.5.3/src/webbee/__init__.py +1 -0
- webbee-0.5.3/src/webbee/http.py +64 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/repl.py +6 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/session.py +3 -2
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/sizing.py +11 -3
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/stream.py +4 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/tools.py +4 -1
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/tui.py +21 -7
- webbee-0.5.3/tests/test_home_view_mouse.py +11 -0
- webbee-0.5.3/tests/test_resilient_http.py +21 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_sizing.py +2 -1
- webbee-0.5.3/tests/test_tab_bar_stability.py +38 -0
- webbee-0.5.3/tests/test_tier_invalidation.py +20 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_tools.py +9 -0
- webbee-0.5.2/src/webbee/__init__.py +0 -1
- webbee-0.5.2/src/webbee/http.py +0 -11
- {webbee-0.5.2 → webbee-0.5.3}/.github/workflows/publish.yml +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/.gitignore +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/LICENSE +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/README.md +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/install.sh +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/__main__.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/account.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/active_sessions.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/banner_art.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/boot.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/browser.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/checkpoints.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/cli.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/clipboard.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/clipboard_read.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/clipboard_session.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/coding_context.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/commands.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/config.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/consent.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/details.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/events.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/frames.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/home.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/home_view.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/instance_lock.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/graph.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/outline.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/query.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/service.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/store.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/watch.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/media.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/modals.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/mode_store.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/native_files.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/native_read.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/newtab_mode.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/output_pane.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/pricing_constants.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/queue_panel.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/reflow.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/remote.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/render.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/repo.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/selection.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/sessions.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/slots.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/steer.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/tab_store.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/tabs.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/thread.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/tier_store.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/todo_panel.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/tokens.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/update.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/urlopen.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/wallet.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/src/webbee/worktrees.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/__init__.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/conftest.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_account.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_active_sessions.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_audit_2026_07_25_findings.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_checkpoints.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_cli.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_clipboard.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_clipboard_read.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_coding_context.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_commands.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_config.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_cpc_outline.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_cross_loop_token.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_details.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_events.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_freeze_fix.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_home.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_home_view.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_inspect_page.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_instance_lock.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_chunker.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_embed.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_graph.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_query.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_service.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_store.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_vectors.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_watch.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_marathon.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_modals_e2e.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_mode_store.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_native_files.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_newtab_mode.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_packaging.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_queue_manage_0337.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_reflow.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_render.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_repl.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_repo.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_session.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_sessions.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_slots.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_steer.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_stream.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_tab_restore_0337.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_tabs.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_terminal_ux_0336.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_thread.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_tier_store.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_todo_panel.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_tokens.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_tools_vision_websearch.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_tui.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_tui_hardening.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_update.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_update_badge_0340.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_urlopen.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_version.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_version_badge_corner.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_wallet.py +0 -0
- {webbee-0.5.2 → webbee-0.5.3}/tests/test_worktrees.py +0 -0
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.3
|
|
4
|
+
|
|
5
|
+
* **grep finds lines in a single file**: a grep whose path is one file searched nothing and answered
|
|
6
|
+
"(no matches)" -- the agent concluded things were missing that were there.
|
|
7
|
+
* **The connection survives sleep and network changes**: sockets carry TCP keepalive (a connection that died
|
|
8
|
+
while the laptop slept is found dead in about a minute instead of hanging the stream), idle pooled
|
|
9
|
+
connections expire after 30s, and the stream reconnects at once after a wake.
|
|
10
|
+
* **The tab bar never vanishes**: it is one fixed row that never wraps or scrolls; a broken terminal size
|
|
11
|
+
falls back to sane bounds.
|
|
12
|
+
* **A UI that fails to start says why** on the real terminal, not only in `/tmp/dock_boot_error.log`.
|
|
13
|
+
|
|
3
14
|
## 0.5.2
|
|
4
15
|
|
|
5
16
|
* **One server, one machine**: a repo synced over your own `ssh` reports every host key of that server your
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.3"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""ONE keep-alive httpx.AsyncClient for the client's small gateway calls.
|
|
2
|
+
Before W1 the idle-steer poller opened a NEW AsyncClient — a fresh TCP+TLS
|
|
3
|
+
handshake — every 4s tick, forever (thread.py per-call clients). The repl owns
|
|
4
|
+
one client for the loop's lifetime and passes it down; every callee keeps a
|
|
5
|
+
per-call fallback so tests and old call sites work unchanged.
|
|
6
|
+
|
|
7
|
+
Sleep and network changes: the sockets carry TCP keepalive, so a connection
|
|
8
|
+
that died while the laptop slept is found dead within about a minute instead of
|
|
9
|
+
hanging a stream forever; idle pooled connections expire after 30s, so the next
|
|
10
|
+
call after a wake opens a fresh one. ``WakeDetector`` tells a reconnect loop the
|
|
11
|
+
machine slept, so it reconnects at once instead of waiting out its backoff."""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import socket
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
# keepalive probes: first after 30s idle, then every 10s, dead after 3 misses
|
|
18
|
+
_KEEPIDLE_S, _KEEPINTVL_S, _KEEPCNT = 30, 10, 3
|
|
19
|
+
_SLEEP_GAP_S = 8.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WakeDetector:
|
|
23
|
+
"""Did the machine sleep since the last ``check()``? The wall clock keeps running while a laptop
|
|
24
|
+
sleeps and the monotonic clock does not (Linux, macOS), so their drift is the time asleep. A long
|
|
25
|
+
idle wait is never a sleep: both clocks move together. One instance per loop -- never shared."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, gap_s: float = _SLEEP_GAP_S, *, wall=time.time, mono=time.monotonic):
|
|
28
|
+
self._gap, self._wall, self._mono = float(gap_s), wall, mono
|
|
29
|
+
self._w, self._m = wall(), mono()
|
|
30
|
+
|
|
31
|
+
def check(self) -> bool:
|
|
32
|
+
w, m = self._wall(), self._mono()
|
|
33
|
+
slept = (w - self._w) - (m - self._m) > self._gap
|
|
34
|
+
self._w, self._m = w, m
|
|
35
|
+
return slept
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def socket_options() -> list:
|
|
39
|
+
"""TCP keepalive options this OS supports (Linux: TCP_KEEPIDLE; macOS: TCP_KEEPALIVE)."""
|
|
40
|
+
opts = []
|
|
41
|
+
if hasattr(socket, "SO_KEEPALIVE"):
|
|
42
|
+
opts.append((socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1))
|
|
43
|
+
for idle in ("TCP_KEEPIDLE", "TCP_KEEPALIVE"):
|
|
44
|
+
if hasattr(socket, idle):
|
|
45
|
+
opts.append((socket.IPPROTO_TCP, getattr(socket, idle), _KEEPIDLE_S))
|
|
46
|
+
break
|
|
47
|
+
if hasattr(socket, "TCP_KEEPINTVL"):
|
|
48
|
+
opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, _KEEPINTVL_S))
|
|
49
|
+
if hasattr(socket, "TCP_KEEPCNT"):
|
|
50
|
+
opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, _KEEPCNT))
|
|
51
|
+
return opts
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_transport(retries: int = 1):
|
|
55
|
+
"""An AsyncHTTPTransport with TCP keepalive and a short idle expiry (``retries``: connect retries)."""
|
|
56
|
+
import httpx
|
|
57
|
+
return httpx.AsyncHTTPTransport(
|
|
58
|
+
retries=retries, socket_options=socket_options(),
|
|
59
|
+
limits=httpx.Limits(max_keepalive_connections=20, max_connections=50, keepalive_expiry=30.0))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def make_client(cfg, timeout: float = 10.0):
|
|
63
|
+
import httpx
|
|
64
|
+
return httpx.AsyncClient(base_url=cfg.api_url, timeout=timeout, transport=build_transport())
|
|
@@ -1737,6 +1737,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1737
1737
|
# log; the contextlib redirect below additionally covers Python-level
|
|
1738
1738
|
# sys.stderr writers. Fail-soft: on any failure leave fd 2 untouched.
|
|
1739
1739
|
_saved_stderr_fd = None
|
|
1740
|
+
_boot_error = ""
|
|
1740
1741
|
try:
|
|
1741
1742
|
_saved_stderr_fd = os.dup(2)
|
|
1742
1743
|
os.dup2(_errlog.fileno(), 2)
|
|
@@ -1993,6 +1994,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1993
1994
|
import traceback
|
|
1994
1995
|
with open("/tmp/dock_boot_error.log", "w") as f:
|
|
1995
1996
|
traceback.print_exc(file=f)
|
|
1997
|
+
_boot_error = f"{type(e).__name__}: {e}"
|
|
1996
1998
|
ok = False
|
|
1997
1999
|
finally:
|
|
1998
2000
|
# Restore the real fd 2 BEFORE the transcript dump / any later
|
|
@@ -2007,6 +2009,10 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
2007
2009
|
_errlog.close()
|
|
2008
2010
|
except Exception:
|
|
2009
2011
|
pass
|
|
2012
|
+
if _boot_error: # said on the REAL stderr (fd 2 is restored): never a silent fallback
|
|
2013
|
+
sys.stderr.write(f"\r\n[webbee] the terminal UI failed to start: {_boot_error[:300]}\r\n"
|
|
2014
|
+
" details: /tmp/dock_boot_error.log\r\n")
|
|
2015
|
+
sys.stderr.flush()
|
|
2010
2016
|
if ok:
|
|
2011
2017
|
# the alt screen is gone — reprint EVERY session tab's own
|
|
2012
2018
|
# scrollback to real stdout (Task 7: was just the one slot that
|
|
@@ -3,6 +3,7 @@ import time
|
|
|
3
3
|
from collections import OrderedDict, deque
|
|
4
4
|
|
|
5
5
|
from webbee.coding_context import build_coding_context, detect_verify_cmd
|
|
6
|
+
from webbee.http import make_client
|
|
6
7
|
from webbee.consent import _retire, race_consent
|
|
7
8
|
from webbee.frames import (
|
|
8
9
|
_MARATHON_FACT_TYPES,
|
|
@@ -232,7 +233,7 @@ class AgentSession:
|
|
|
232
233
|
body["goal"] = goal
|
|
233
234
|
|
|
234
235
|
headers = await self._headers()
|
|
235
|
-
async with
|
|
236
|
+
async with make_client(self.cfg, timeout=60) as client: # keepalive: a slept socket dies, never hangs
|
|
236
237
|
resp = await _transient_retry(lambda: client.post(
|
|
237
238
|
"/v1/agent/sessions", json=body, headers=headers))
|
|
238
239
|
resp.raise_for_status()
|
|
@@ -274,7 +275,7 @@ class AgentSession:
|
|
|
274
275
|
executor = LocalToolExecutor(self.workspace_root, indexer=self._intel,
|
|
275
276
|
shadow=self._shadow,
|
|
276
277
|
client_factory=self._file_client_factory())
|
|
277
|
-
async with
|
|
278
|
+
async with make_client(self.cfg, timeout=60) as client: # keepalive: a slept socket dies, never hangs
|
|
278
279
|
return await self._serve_stream(sink, client, session_id, start_id, task_id,
|
|
279
280
|
executor, marathon=marathon)
|
|
280
281
|
|
|
@@ -12,12 +12,20 @@ def get_size(app=None) -> tuple[int, int]:
|
|
|
12
12
|
if out is not None and hasattr(out, "get_size"):
|
|
13
13
|
try:
|
|
14
14
|
s = out.get_size()
|
|
15
|
-
|
|
15
|
+
cols = int(getattr(s, "columns", 0))
|
|
16
|
+
rows = int(getattr(s, "rows", 0))
|
|
17
|
+
if cols > 0 and rows > 0:
|
|
18
|
+
return max(40, cols), max(10, rows)
|
|
16
19
|
except Exception:
|
|
17
20
|
pass
|
|
18
21
|
import shutil
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
try:
|
|
23
|
+
s = shutil.get_terminal_size((100, 24))
|
|
24
|
+
cols = int(getattr(s, "columns", 100))
|
|
25
|
+
rows = int(getattr(s, "lines", 24))
|
|
26
|
+
return max(40, cols if cols > 0 else 100), max(10, rows if rows > 0 else 24)
|
|
27
|
+
except Exception:
|
|
28
|
+
return 100, 24
|
|
21
29
|
|
|
22
30
|
|
|
23
31
|
def input_height_cap(rows: int) -> int:
|
|
@@ -51,6 +51,8 @@ class _TransientStatus(Exception):
|
|
|
51
51
|
|
|
52
52
|
async def stream_frames(client, session_id: str, headers_provider, *,
|
|
53
53
|
start_id: str = "0-0", force_refresh=None, on_retry=None):
|
|
54
|
+
from webbee.http import WakeDetector
|
|
55
|
+
woke = WakeDetector()
|
|
54
56
|
last_id = start_id or "0-0"
|
|
55
57
|
backoff = _BACKOFF_BASE
|
|
56
58
|
attempt = 0
|
|
@@ -105,6 +107,8 @@ async def stream_frames(client, session_id: str, headers_provider, *,
|
|
|
105
107
|
yield frame
|
|
106
108
|
except (httpx.HTTPError, OSError, _TransientStatus, _TransientAuth):
|
|
107
109
|
attempt += 1
|
|
110
|
+
if woke.check():
|
|
111
|
+
backoff = _BACKOFF_BASE # the machine slept: reconnect now, not after the old backoff
|
|
108
112
|
delay = min(backoff, _BACKOFF_MAX)
|
|
109
113
|
if on_retry is not None:
|
|
110
114
|
try:
|
|
@@ -627,7 +627,10 @@ class LocalToolExecutor:
|
|
|
627
627
|
pat = re.compile(a["pattern"])
|
|
628
628
|
base = self.resolve_in_workspace(a.get("path", "."))
|
|
629
629
|
hits = []
|
|
630
|
-
|
|
630
|
+
# a single file is searched itself: os.walk() yields nothing for a file, so every grep on
|
|
631
|
+
# one file answered "(no matches)" (live 2026-10-20: 30 of 34 single-file greps were false)
|
|
632
|
+
walk = [(os.path.dirname(base), [], [os.path.basename(base)])] if os.path.isfile(base) else os.walk(base)
|
|
633
|
+
for dp, dns, fns in walk:
|
|
631
634
|
if _is_git_dir(dp):
|
|
632
635
|
dns[:] = []
|
|
633
636
|
continue
|
|
@@ -21,6 +21,8 @@ import asyncio
|
|
|
21
21
|
import os
|
|
22
22
|
import re
|
|
23
23
|
|
|
24
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
25
|
+
|
|
24
26
|
from webbee import __version__, sizing
|
|
25
27
|
from webbee.output_pane import OutputPane # noqa: F401 — re-exported (webbee.tui.OutputPane)
|
|
26
28
|
from webbee.queue_panel import (drop_item, one_line, pull_item, queue_fragments,
|
|
@@ -1047,7 +1049,7 @@ async def run_session(*, slots, on_line, on_cycle, on_tier_cycle=None,
|
|
|
1047
1049
|
from prompt_toolkit.filters import Condition
|
|
1048
1050
|
from prompt_toolkit.key_binding import KeyBindings
|
|
1049
1051
|
from prompt_toolkit.layout import (ConditionalContainer, DynamicContainer,
|
|
1050
|
-
HSplit, Layout, Window)
|
|
1052
|
+
HSplit, Layout, Window, Dimension)
|
|
1051
1053
|
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
1052
1054
|
from prompt_toolkit.layout.processors import BeforeInput
|
|
1053
1055
|
from prompt_toolkit.styles import Style
|
|
@@ -2367,6 +2369,16 @@ async def run_session(*, slots, on_line, on_cycle, on_tier_cycle=None,
|
|
|
2367
2369
|
|
|
2368
2370
|
def _tab_fragments_live():
|
|
2369
2371
|
cols, _rows = sizing.get_size(get_app_or_none())
|
|
2372
|
+
cols = max(cols, 20)
|
|
2373
|
+
# INVARIANT: tab_bar Window must NEVER scroll vertically.
|
|
2374
|
+
# If vertical_scroll ever reaches 1, prompt_toolkit renders line 1 (empty),
|
|
2375
|
+
# which makes the entire top tab bar disappear or get 'eaten' by the screen.
|
|
2376
|
+
if "tab_bar" in locals() and tab_bar is not None:
|
|
2377
|
+
try:
|
|
2378
|
+
tab_bar.vertical_scroll = 0
|
|
2379
|
+
tab_bar.horizontal_scroll = 0
|
|
2380
|
+
except Exception:
|
|
2381
|
+
pass
|
|
2370
2382
|
return tab_fragments(slots, on_switch=_switch_to, on_close=_close_tab_click,
|
|
2371
2383
|
on_new=_new_tab_click, width=cols,
|
|
2372
2384
|
forward=lambda ev: _pane().forward_mouse(ev, clamp="top"),
|
|
@@ -2381,12 +2393,14 @@ async def run_session(*, slots, on_line, on_cycle, on_tier_cycle=None,
|
|
|
2381
2393
|
# every chip on its own solid bar (`"tabbar": "bg:#262626"` in the Style
|
|
2382
2394
|
# dict below) — a browser look, visually separated from the transcript
|
|
2383
2395
|
# above/below it instead of floating directly on the terminal's own bg.
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
#
|
|
2387
|
-
#
|
|
2388
|
-
|
|
2389
|
-
|
|
2396
|
+
# CRITICAL FIX (top bar disappearing): wrap_lines=False, always_hide_cursor=True,
|
|
2397
|
+
# and dont_extend_height=True prevent prompt_toolkit from scrolling
|
|
2398
|
+
# vertically or wrapping tabs into line 2, which caused the entire top bar
|
|
2399
|
+
# to disappear or get 'eaten' during tab switching, resize or mouse wheel scroll.
|
|
2400
|
+
tab_bar = Window(FormattedTextControl(_tab_fragments_live, focusable=False, show_cursor=False),
|
|
2401
|
+
height=Dimension.exact(1), always_hide_cursor=True,
|
|
2402
|
+
wrap_lines=False, style="class:tabbar", dont_extend_height=True)
|
|
2403
|
+
tab_bar_spacer = Window(height=Dimension.exact(1), dont_extend_height=True)
|
|
2390
2404
|
# The single most structural change of the W4a wave (map §3): the pane
|
|
2391
2405
|
# slot in the root layout is a DynamicContainer, not a bound window —
|
|
2392
2406
|
# it re-resolves `slots.active().pane.window` on EVERY redraw, so a tab
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from webbee.slots import SlotManager, SessionSlot
|
|
3
|
+
from webbee.home_view import HomeView
|
|
4
|
+
from webbee.output_pane import OutputPane
|
|
5
|
+
|
|
6
|
+
def test_home_view_has_forward_mouse():
|
|
7
|
+
slots = SlotManager()
|
|
8
|
+
hv = HomeView(slots=slots, actions={})
|
|
9
|
+
assert hasattr(hv, "forward_mouse"), "HomeView must have forward_mouse to prevent AttributeError on tab switch or mouse events"
|
|
10
|
+
# Should accept ev and clamp without crashing
|
|
11
|
+
hv.forward_mouse(None, clamp="top")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from webbee.http import WakeDetector, make_client, socket_options
|
|
2
|
+
from webbee.config import Config
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_a_long_idle_wait_is_not_a_sleep_but_a_clock_drift_is():
|
|
6
|
+
t = {"wall": 1000.0, "mono": 50.0}
|
|
7
|
+
d = WakeDetector(8.0, wall=lambda: t["wall"], mono=lambda: t["mono"])
|
|
8
|
+
t["wall"] += 60; t["mono"] += 60 # 60s idle: both clocks move
|
|
9
|
+
assert d.check() is False
|
|
10
|
+
t["wall"] += 600; t["mono"] += 2 # the laptop slept ~10 minutes
|
|
11
|
+
assert d.check() is True
|
|
12
|
+
t["wall"] += 4; t["mono"] += 4
|
|
13
|
+
assert d.check() is False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_the_client_sockets_carry_keepalive():
|
|
17
|
+
import socket
|
|
18
|
+
opts = socket_options()
|
|
19
|
+
assert (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) in opts
|
|
20
|
+
client = make_client(Config(api_url="https://api.imperal.io", panel_url="https://panel.imperal.io"))
|
|
21
|
+
assert client.timeout.read == 10.0
|
|
@@ -11,8 +11,9 @@ def test_get_size_prefers_pt_output():
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def test_get_size_falls_back_without_app(monkeypatch):
|
|
14
|
+
import os
|
|
14
15
|
import shutil
|
|
15
|
-
monkeypatch.setattr(shutil, "get_terminal_size", lambda
|
|
16
|
+
monkeypatch.setattr(shutil, "get_terminal_size", lambda *args, **kwargs: os.terminal_size((91, 22)))
|
|
16
17
|
assert get_size(None) == (91, 22)
|
|
17
18
|
|
|
18
19
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from prompt_toolkit.layout.containers import Window
|
|
3
|
+
from webbee.slots import SlotManager
|
|
4
|
+
from webbee.tabs import tab_fragments
|
|
5
|
+
import webbee.sizing as sizing
|
|
6
|
+
|
|
7
|
+
def test_tab_bar_clamping_and_no_wrap():
|
|
8
|
+
from webbee.slots import SessionSlot
|
|
9
|
+
sm = SlotManager()
|
|
10
|
+
# Add a slot
|
|
11
|
+
slot2 = SessionSlot(kind="session", workspace="/tmp", label="Tab 1", pane=None, sink=None, agent=None)
|
|
12
|
+
sm.add(slot2)
|
|
13
|
+
assert sm.session_count() == 1
|
|
14
|
+
|
|
15
|
+
# Check tab fragments with normal width
|
|
16
|
+
frags = tab_fragments(sm, on_switch=lambda x: None, on_close=lambda x: None, width=80)
|
|
17
|
+
assert len(frags) > 0
|
|
18
|
+
|
|
19
|
+
# Check tab fragments with narrow width - should never raise or collapse
|
|
20
|
+
frags_narrow = tab_fragments(sm, on_switch=lambda x: None, on_close=lambda x: None, width=10)
|
|
21
|
+
assert len(frags_narrow) > 0
|
|
22
|
+
|
|
23
|
+
# Check tab fragments with zero or negative width
|
|
24
|
+
frags_zero = tab_fragments(sm, on_switch=lambda x: None, on_close=lambda x: None, width=0)
|
|
25
|
+
assert len(frags_zero) > 0
|
|
26
|
+
frags_neg = tab_fragments(sm, on_switch=lambda x: None, on_close=lambda x: None, width=-5)
|
|
27
|
+
assert len(frags_neg) > 0
|
|
28
|
+
|
|
29
|
+
def test_tab_bar_window_scroll_invariants():
|
|
30
|
+
# Verify that Window representing tab bar has always_hide_cursor and wrap_lines=False
|
|
31
|
+
# to avoid prompt_toolkit line wrapping or vertical scrolling that eats the top bar
|
|
32
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
33
|
+
from prompt_toolkit.filters import to_filter
|
|
34
|
+
control = FormattedTextControl([("", "tab text")])
|
|
35
|
+
w = Window(content=control, height=1, dont_extend_height=True, wrap_lines=False, always_hide_cursor=True)
|
|
36
|
+
assert to_filter(w.wrap_lines)() is False
|
|
37
|
+
assert to_filter(w.always_hide_cursor)() is True
|
|
38
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from webbee.slots import SlotManager, SessionSlot
|
|
3
|
+
from webbee.repl import set_slot_tier
|
|
4
|
+
from webbee.tier_store import load_tier, save_tier
|
|
5
|
+
|
|
6
|
+
def test_set_slot_tier_propagates_and_invalidates(tmp_path):
|
|
7
|
+
class DummyAgent:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.model_tier = ""
|
|
10
|
+
self.invalidated = False
|
|
11
|
+
def invalidate_cache(self):
|
|
12
|
+
self.invalidated = True
|
|
13
|
+
|
|
14
|
+
workspace = str(tmp_path)
|
|
15
|
+
slot = SessionSlot(kind="session", workspace=workspace, label="TestSlot", pane=None, sink=None, agent=DummyAgent())
|
|
16
|
+
|
|
17
|
+
set_slot_tier(slot, "ultrasmart")
|
|
18
|
+
assert slot.model_tier == "ultrasmart"
|
|
19
|
+
assert slot.agent.model_tier == "ultrasmart"
|
|
20
|
+
assert load_tier(workspace) == "ultrasmart"
|
|
@@ -37,6 +37,15 @@ def test_grep(tmp_path):
|
|
|
37
37
|
assert r["ok"] and "a.py" in r["content"]
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def test_grep_on_a_single_file(tmp_path):
|
|
41
|
+
"""live 2026-10-20: every grep whose path was one file answered "(no matches)"."""
|
|
42
|
+
ex = _ex(tmp_path)
|
|
43
|
+
ex.run("write_file", {"path": "app/writers.py", "content": "x = 1\n@writer\ndef save():\n pass\n"})
|
|
44
|
+
r = ex.run("grep", {"pattern": "@writer", "path": "app/writers.py"})
|
|
45
|
+
assert r["ok"] and r["match_count"] == 1 and r["content"] == os.path.join("app", "writers.py") + ":2:@writer"
|
|
46
|
+
assert ex.run("grep", {"pattern": "nothing", "path": "app/writers.py"})["content"] == "(no matches)"
|
|
47
|
+
|
|
48
|
+
|
|
40
49
|
def test_grep_skips_git_dir(tmp_path):
|
|
41
50
|
# Real regression coverage on this (posix) host: a matching file sitting
|
|
42
51
|
# inside .git/ must never surface in results.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.5.2"
|
webbee-0.5.2/src/webbee/http.py
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
"""ONE keep-alive httpx.AsyncClient for the client's small gateway calls.
|
|
2
|
-
Before W1 the idle-steer poller opened a NEW AsyncClient — a fresh TCP+TLS
|
|
3
|
-
handshake — every 4s tick, forever (thread.py per-call clients). The repl owns
|
|
4
|
-
one client for the loop's lifetime and passes it down; every callee keeps a
|
|
5
|
-
per-call fallback so tests and old call sites work unchanged."""
|
|
6
|
-
from __future__ import annotations
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def make_client(cfg):
|
|
10
|
-
import httpx
|
|
11
|
-
return httpx.AsyncClient(base_url=cfg.api_url, timeout=10)
|
|
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
|
|
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
|