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.
Files changed (144) hide show
  1. {webbee-0.5.2 → webbee-0.5.3}/CHANGELOG.md +11 -0
  2. {webbee-0.5.2 → webbee-0.5.3}/PKG-INFO +1 -1
  3. {webbee-0.5.2 → webbee-0.5.3}/pyproject.toml +1 -1
  4. webbee-0.5.3/src/webbee/__init__.py +1 -0
  5. webbee-0.5.3/src/webbee/http.py +64 -0
  6. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/repl.py +6 -0
  7. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/session.py +3 -2
  8. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/sizing.py +11 -3
  9. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/stream.py +4 -0
  10. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/tools.py +4 -1
  11. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/tui.py +21 -7
  12. webbee-0.5.3/tests/test_home_view_mouse.py +11 -0
  13. webbee-0.5.3/tests/test_resilient_http.py +21 -0
  14. {webbee-0.5.2 → webbee-0.5.3}/tests/test_sizing.py +2 -1
  15. webbee-0.5.3/tests/test_tab_bar_stability.py +38 -0
  16. webbee-0.5.3/tests/test_tier_invalidation.py +20 -0
  17. {webbee-0.5.2 → webbee-0.5.3}/tests/test_tools.py +9 -0
  18. webbee-0.5.2/src/webbee/__init__.py +0 -1
  19. webbee-0.5.2/src/webbee/http.py +0 -11
  20. {webbee-0.5.2 → webbee-0.5.3}/.github/workflows/publish.yml +0 -0
  21. {webbee-0.5.2 → webbee-0.5.3}/.gitignore +0 -0
  22. {webbee-0.5.2 → webbee-0.5.3}/LICENSE +0 -0
  23. {webbee-0.5.2 → webbee-0.5.3}/README.md +0 -0
  24. {webbee-0.5.2 → webbee-0.5.3}/install.sh +0 -0
  25. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/__main__.py +0 -0
  26. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/account.py +0 -0
  27. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/active_sessions.py +0 -0
  28. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/banner_art.py +0 -0
  29. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/boot.py +0 -0
  30. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/browser.py +0 -0
  31. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/checkpoints.py +0 -0
  32. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/cli.py +0 -0
  33. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/clipboard.py +0 -0
  34. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/clipboard_read.py +0 -0
  35. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/clipboard_session.py +0 -0
  36. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/coding_context.py +0 -0
  37. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/commands.py +0 -0
  38. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/config.py +0 -0
  39. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/consent.py +0 -0
  40. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/details.py +0 -0
  41. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/events.py +0 -0
  42. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/frames.py +0 -0
  43. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/home.py +0 -0
  44. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/home_view.py +0 -0
  45. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/instance_lock.py +0 -0
  46. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/__init__.py +0 -0
  47. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/graph.py +0 -0
  48. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/outline.py +0 -0
  49. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/query.py +0 -0
  50. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/service.py +0 -0
  51. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/store.py +0 -0
  52. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/vectors.py +0 -0
  53. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/intel/watch.py +0 -0
  54. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/media.py +0 -0
  55. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/modals.py +0 -0
  56. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/mode_store.py +0 -0
  57. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/native_files.py +0 -0
  58. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/native_read.py +0 -0
  59. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/newtab_mode.py +0 -0
  60. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/output_pane.py +0 -0
  61. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/pricing_constants.py +0 -0
  62. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/queue_panel.py +0 -0
  63. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/reflow.py +0 -0
  64. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/remote.py +0 -0
  65. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/render.py +0 -0
  66. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/repo.py +0 -0
  67. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/selection.py +0 -0
  68. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/sessions.py +0 -0
  69. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/slots.py +0 -0
  70. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/steer.py +0 -0
  71. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/tab_store.py +0 -0
  72. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/tabs.py +0 -0
  73. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/thread.py +0 -0
  74. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/tier_store.py +0 -0
  75. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/todo_panel.py +0 -0
  76. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/tokens.py +0 -0
  77. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/update.py +0 -0
  78. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/urlopen.py +0 -0
  79. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/wallet.py +0 -0
  80. {webbee-0.5.2 → webbee-0.5.3}/src/webbee/worktrees.py +0 -0
  81. {webbee-0.5.2 → webbee-0.5.3}/tests/__init__.py +0 -0
  82. {webbee-0.5.2 → webbee-0.5.3}/tests/conftest.py +0 -0
  83. {webbee-0.5.2 → webbee-0.5.3}/tests/test_account.py +0 -0
  84. {webbee-0.5.2 → webbee-0.5.3}/tests/test_active_sessions.py +0 -0
  85. {webbee-0.5.2 → webbee-0.5.3}/tests/test_audit_2026_07_25_findings.py +0 -0
  86. {webbee-0.5.2 → webbee-0.5.3}/tests/test_checkpoints.py +0 -0
  87. {webbee-0.5.2 → webbee-0.5.3}/tests/test_cli.py +0 -0
  88. {webbee-0.5.2 → webbee-0.5.3}/tests/test_clipboard.py +0 -0
  89. {webbee-0.5.2 → webbee-0.5.3}/tests/test_clipboard_read.py +0 -0
  90. {webbee-0.5.2 → webbee-0.5.3}/tests/test_coding_context.py +0 -0
  91. {webbee-0.5.2 → webbee-0.5.3}/tests/test_commands.py +0 -0
  92. {webbee-0.5.2 → webbee-0.5.3}/tests/test_config.py +0 -0
  93. {webbee-0.5.2 → webbee-0.5.3}/tests/test_cpc_contract_stable.py +0 -0
  94. {webbee-0.5.2 → webbee-0.5.3}/tests/test_cpc_outline.py +0 -0
  95. {webbee-0.5.2 → webbee-0.5.3}/tests/test_cross_loop_token.py +0 -0
  96. {webbee-0.5.2 → webbee-0.5.3}/tests/test_details.py +0 -0
  97. {webbee-0.5.2 → webbee-0.5.3}/tests/test_events.py +0 -0
  98. {webbee-0.5.2 → webbee-0.5.3}/tests/test_freeze_fix.py +0 -0
  99. {webbee-0.5.2 → webbee-0.5.3}/tests/test_home.py +0 -0
  100. {webbee-0.5.2 → webbee-0.5.3}/tests/test_home_view.py +0 -0
  101. {webbee-0.5.2 → webbee-0.5.3}/tests/test_inspect_page.py +0 -0
  102. {webbee-0.5.2 → webbee-0.5.3}/tests/test_instance_lock.py +0 -0
  103. {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_chunker.py +0 -0
  104. {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_embed.py +0 -0
  105. {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_graph.py +0 -0
  106. {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_no_numpy.py +0 -0
  107. {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_query.py +0 -0
  108. {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_service.py +0 -0
  109. {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_store.py +0 -0
  110. {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_vectors.py +0 -0
  111. {webbee-0.5.2 → webbee-0.5.3}/tests/test_intel_watch.py +0 -0
  112. {webbee-0.5.2 → webbee-0.5.3}/tests/test_marathon.py +0 -0
  113. {webbee-0.5.2 → webbee-0.5.3}/tests/test_modals_e2e.py +0 -0
  114. {webbee-0.5.2 → webbee-0.5.3}/tests/test_mode_store.py +0 -0
  115. {webbee-0.5.2 → webbee-0.5.3}/tests/test_native_files.py +0 -0
  116. {webbee-0.5.2 → webbee-0.5.3}/tests/test_newtab_mode.py +0 -0
  117. {webbee-0.5.2 → webbee-0.5.3}/tests/test_packaging.py +0 -0
  118. {webbee-0.5.2 → webbee-0.5.3}/tests/test_queue_manage_0337.py +0 -0
  119. {webbee-0.5.2 → webbee-0.5.3}/tests/test_reflow.py +0 -0
  120. {webbee-0.5.2 → webbee-0.5.3}/tests/test_render.py +0 -0
  121. {webbee-0.5.2 → webbee-0.5.3}/tests/test_repl.py +0 -0
  122. {webbee-0.5.2 → webbee-0.5.3}/tests/test_repo.py +0 -0
  123. {webbee-0.5.2 → webbee-0.5.3}/tests/test_session.py +0 -0
  124. {webbee-0.5.2 → webbee-0.5.3}/tests/test_sessions.py +0 -0
  125. {webbee-0.5.2 → webbee-0.5.3}/tests/test_slots.py +0 -0
  126. {webbee-0.5.2 → webbee-0.5.3}/tests/test_steer.py +0 -0
  127. {webbee-0.5.2 → webbee-0.5.3}/tests/test_stream.py +0 -0
  128. {webbee-0.5.2 → webbee-0.5.3}/tests/test_tab_restore_0337.py +0 -0
  129. {webbee-0.5.2 → webbee-0.5.3}/tests/test_tabs.py +0 -0
  130. {webbee-0.5.2 → webbee-0.5.3}/tests/test_terminal_ux_0336.py +0 -0
  131. {webbee-0.5.2 → webbee-0.5.3}/tests/test_thread.py +0 -0
  132. {webbee-0.5.2 → webbee-0.5.3}/tests/test_tier_store.py +0 -0
  133. {webbee-0.5.2 → webbee-0.5.3}/tests/test_todo_panel.py +0 -0
  134. {webbee-0.5.2 → webbee-0.5.3}/tests/test_tokens.py +0 -0
  135. {webbee-0.5.2 → webbee-0.5.3}/tests/test_tools_vision_websearch.py +0 -0
  136. {webbee-0.5.2 → webbee-0.5.3}/tests/test_tui.py +0 -0
  137. {webbee-0.5.2 → webbee-0.5.3}/tests/test_tui_hardening.py +0 -0
  138. {webbee-0.5.2 → webbee-0.5.3}/tests/test_update.py +0 -0
  139. {webbee-0.5.2 → webbee-0.5.3}/tests/test_update_badge_0340.py +0 -0
  140. {webbee-0.5.2 → webbee-0.5.3}/tests/test_urlopen.py +0 -0
  141. {webbee-0.5.2 → webbee-0.5.3}/tests/test_version.py +0 -0
  142. {webbee-0.5.2 → webbee-0.5.3}/tests/test_version_badge_corner.py +0 -0
  143. {webbee-0.5.2 → webbee-0.5.3}/tests/test_wallet.py +0 -0
  144. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: webbee
3
- Version: 0.5.2
3
+ Version: 0.5.3
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.5.2"
3
+ version = "0.5.3"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-or-later"
@@ -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 httpx.AsyncClient(base_url=self.cfg.api_url, timeout=60) as client:
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 httpx.AsyncClient(base_url=self.cfg.api_url, timeout=60) as client:
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
- return int(s.columns), int(s.rows)
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
- s = shutil.get_terminal_size((100, 24))
20
- return int(s.columns), int(getattr(s, "lines", 24))
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
- for dp, dns, fns in os.walk(base):
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
- tab_bar = Window(FormattedTextControl(_tab_fragments_live, focusable=False),
2385
- height=1, always_hide_cursor=True, style="class:tabbar")
2386
- # ONE blank row of breathing room between the bar and the transcript —
2387
- # deliberately bare (no style at all): it renders as plain terminal
2388
- # background, transparent-looking, never a second colored stripe.
2389
- tab_bar_spacer = Window(height=1)
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 fb: type("S", (), {"columns": 91, "lines": 22})())
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"
@@ -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