webbee 0.3.34__tar.gz → 0.3.35__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 (123) hide show
  1. {webbee-0.3.34 → webbee-0.3.35}/CHANGELOG.md +21 -0
  2. {webbee-0.3.34 → webbee-0.3.35}/PKG-INFO +1 -1
  3. {webbee-0.3.34 → webbee-0.3.35}/pyproject.toml +1 -1
  4. webbee-0.3.35/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/intel/chunker.py +24 -1
  6. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/intel/query.py +10 -1
  7. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/intel/service.py +30 -1
  8. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/intel/store.py +6 -1
  9. webbee-0.3.35/src/webbee/intel/vectors.py +95 -0
  10. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/mode_store.py +26 -2
  11. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/output_pane.py +1 -0
  12. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/repl.py +19 -1
  13. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/tui.py +59 -18
  14. {webbee-0.3.34 → webbee-0.3.35}/tests/conftest.py +18 -0
  15. webbee-0.3.35/tests/test_audit_2026_07_25_findings.py +479 -0
  16. {webbee-0.3.34 → webbee-0.3.35}/tests/test_intel_chunker.py +3 -1
  17. {webbee-0.3.34 → webbee-0.3.35}/tests/test_tui.py +27 -9
  18. webbee-0.3.34/src/webbee/__init__.py +0 -1
  19. webbee-0.3.34/src/webbee/intel/vectors.py +0 -72
  20. {webbee-0.3.34 → webbee-0.3.35}/.github/workflows/publish.yml +0 -0
  21. {webbee-0.3.34 → webbee-0.3.35}/.gitignore +0 -0
  22. {webbee-0.3.34 → webbee-0.3.35}/LICENSE +0 -0
  23. {webbee-0.3.34 → webbee-0.3.35}/README.md +0 -0
  24. {webbee-0.3.34 → webbee-0.3.35}/docs/superpowers/plans/2026-07-21-interactive-home-dashboard.md +0 -0
  25. {webbee-0.3.34 → webbee-0.3.35}/install.sh +0 -0
  26. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/account.py +0 -0
  27. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/active_sessions.py +0 -0
  28. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/banner_art.py +0 -0
  29. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/boot.py +0 -0
  30. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/checkpoints.py +0 -0
  31. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/cli.py +0 -0
  32. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/clipboard.py +0 -0
  33. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/clipboard_read.py +0 -0
  34. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/coding_context.py +0 -0
  35. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/commands.py +0 -0
  36. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/config.py +0 -0
  37. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/consent.py +0 -0
  38. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/details.py +0 -0
  39. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/events.py +0 -0
  40. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/frames.py +0 -0
  41. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/home.py +0 -0
  42. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/home_view.py +0 -0
  43. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/http.py +0 -0
  44. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/instance_lock.py +0 -0
  45. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/intel/__init__.py +0 -0
  46. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/intel/embed.py +0 -0
  47. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/intel/graph.py +0 -0
  48. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/intel/indexer.py +0 -0
  49. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/intel/models.py +0 -0
  50. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/intel/watch.py +0 -0
  51. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/newtab_mode.py +0 -0
  52. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/queue_panel.py +0 -0
  53. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/reflow.py +0 -0
  54. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/remote.py +0 -0
  55. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/render.py +0 -0
  56. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/repo.py +0 -0
  57. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/selection.py +0 -0
  58. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/session.py +0 -0
  59. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/sessions.py +0 -0
  60. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/sizing.py +0 -0
  61. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/slots.py +0 -0
  62. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/steer.py +0 -0
  63. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/stream.py +0 -0
  64. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/tabs.py +0 -0
  65. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/thread.py +0 -0
  66. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/todo_panel.py +0 -0
  67. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/tokens.py +0 -0
  68. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/tools.py +0 -0
  69. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/update.py +0 -0
  70. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/urlopen.py +0 -0
  71. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/wallet.py +0 -0
  72. {webbee-0.3.34 → webbee-0.3.35}/src/webbee/worktrees.py +0 -0
  73. {webbee-0.3.34 → webbee-0.3.35}/tests/__init__.py +0 -0
  74. {webbee-0.3.34 → webbee-0.3.35}/tests/test_account.py +0 -0
  75. {webbee-0.3.34 → webbee-0.3.35}/tests/test_active_sessions.py +0 -0
  76. {webbee-0.3.34 → webbee-0.3.35}/tests/test_checkpoints.py +0 -0
  77. {webbee-0.3.34 → webbee-0.3.35}/tests/test_cli.py +0 -0
  78. {webbee-0.3.34 → webbee-0.3.35}/tests/test_clipboard.py +0 -0
  79. {webbee-0.3.34 → webbee-0.3.35}/tests/test_clipboard_read.py +0 -0
  80. {webbee-0.3.34 → webbee-0.3.35}/tests/test_coding_context.py +0 -0
  81. {webbee-0.3.34 → webbee-0.3.35}/tests/test_commands.py +0 -0
  82. {webbee-0.3.34 → webbee-0.3.35}/tests/test_config.py +0 -0
  83. {webbee-0.3.34 → webbee-0.3.35}/tests/test_cpc_contract_stable.py +0 -0
  84. {webbee-0.3.34 → webbee-0.3.35}/tests/test_details.py +0 -0
  85. {webbee-0.3.34 → webbee-0.3.35}/tests/test_events.py +0 -0
  86. {webbee-0.3.34 → webbee-0.3.35}/tests/test_freeze_fix.py +0 -0
  87. {webbee-0.3.34 → webbee-0.3.35}/tests/test_home.py +0 -0
  88. {webbee-0.3.34 → webbee-0.3.35}/tests/test_home_view.py +0 -0
  89. {webbee-0.3.34 → webbee-0.3.35}/tests/test_instance_lock.py +0 -0
  90. {webbee-0.3.34 → webbee-0.3.35}/tests/test_intel_embed.py +0 -0
  91. {webbee-0.3.34 → webbee-0.3.35}/tests/test_intel_graph.py +0 -0
  92. {webbee-0.3.34 → webbee-0.3.35}/tests/test_intel_indexer.py +0 -0
  93. {webbee-0.3.34 → webbee-0.3.35}/tests/test_intel_no_numpy.py +0 -0
  94. {webbee-0.3.34 → webbee-0.3.35}/tests/test_intel_query.py +0 -0
  95. {webbee-0.3.34 → webbee-0.3.35}/tests/test_intel_service.py +0 -0
  96. {webbee-0.3.34 → webbee-0.3.35}/tests/test_intel_store.py +0 -0
  97. {webbee-0.3.34 → webbee-0.3.35}/tests/test_intel_vectors.py +0 -0
  98. {webbee-0.3.34 → webbee-0.3.35}/tests/test_intel_watch.py +0 -0
  99. {webbee-0.3.34 → webbee-0.3.35}/tests/test_marathon.py +0 -0
  100. {webbee-0.3.34 → webbee-0.3.35}/tests/test_mode_store.py +0 -0
  101. {webbee-0.3.34 → webbee-0.3.35}/tests/test_newtab_mode.py +0 -0
  102. {webbee-0.3.34 → webbee-0.3.35}/tests/test_packaging.py +0 -0
  103. {webbee-0.3.34 → webbee-0.3.35}/tests/test_reflow.py +0 -0
  104. {webbee-0.3.34 → webbee-0.3.35}/tests/test_render.py +0 -0
  105. {webbee-0.3.34 → webbee-0.3.35}/tests/test_repl.py +0 -0
  106. {webbee-0.3.34 → webbee-0.3.35}/tests/test_repo.py +0 -0
  107. {webbee-0.3.34 → webbee-0.3.35}/tests/test_session.py +0 -0
  108. {webbee-0.3.34 → webbee-0.3.35}/tests/test_sessions.py +0 -0
  109. {webbee-0.3.34 → webbee-0.3.35}/tests/test_sizing.py +0 -0
  110. {webbee-0.3.34 → webbee-0.3.35}/tests/test_slots.py +0 -0
  111. {webbee-0.3.34 → webbee-0.3.35}/tests/test_steer.py +0 -0
  112. {webbee-0.3.34 → webbee-0.3.35}/tests/test_stream.py +0 -0
  113. {webbee-0.3.34 → webbee-0.3.35}/tests/test_tabs.py +0 -0
  114. {webbee-0.3.34 → webbee-0.3.35}/tests/test_thread.py +0 -0
  115. {webbee-0.3.34 → webbee-0.3.35}/tests/test_todo_panel.py +0 -0
  116. {webbee-0.3.34 → webbee-0.3.35}/tests/test_tokens.py +0 -0
  117. {webbee-0.3.34 → webbee-0.3.35}/tests/test_tools.py +0 -0
  118. {webbee-0.3.34 → webbee-0.3.35}/tests/test_tui_hardening.py +0 -0
  119. {webbee-0.3.34 → webbee-0.3.35}/tests/test_update.py +0 -0
  120. {webbee-0.3.34 → webbee-0.3.35}/tests/test_urlopen.py +0 -0
  121. {webbee-0.3.34 → webbee-0.3.35}/tests/test_version.py +0 -0
  122. {webbee-0.3.34 → webbee-0.3.35}/tests/test_wallet.py +0 -0
  123. {webbee-0.3.34 → webbee-0.3.35}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.35
4
+
5
+ Reliability and speed of the terminal itself.
6
+
7
+ - Code intelligence no longer dies silently at startup. On many repositories it
8
+ failed while building its index and the terminal simply said "code intel off"
9
+ with no reason — semantic search and the code graph were quietly unavailable
10
+ for the whole session. It now builds correctly (and old broken caches are
11
+ rebuilt automatically).
12
+ - Resizing the window is smooth again: dragging a split no longer re-wraps the
13
+ text on every intermediate width, so the terminal stops freezing mid-drag.
14
+ - Ctrl+V no longer blocks the terminal while reading the clipboard, so pasting
15
+ a large image can't lock up the interface.
16
+ - Switching tabs with Shift+TAB is instant again — it no longer runs a `git`
17
+ command each time (which could stall if git itself hung).
18
+ - Building the project index skips virtualenvs and dependency caches, so a
19
+ large in-tree environment can no longer crowd out your own source files.
20
+ On this repository the full pass went from ~31s to ~1s.
21
+ - Fixed a rare case where a remote approval prompt could vanish before you
22
+ answered it.
23
+
3
24
  ## 0.3.34
4
25
 
5
26
  Paste images with Ctrl+V. Copy a screenshot (or any image) and press Ctrl+V in
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.34
3
+ Version: 0.3.35
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.3.34"
3
+ version = "0.3.35"
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.3.35"
@@ -27,8 +27,31 @@ def _hash(text: str) -> str:
27
27
 
28
28
 
29
29
  def _mk(path: str, symbol: str, kind: str, s: int, e: int, lines: list[str]) -> Chunk:
30
+ """Build one chunk. The id MUST be unique across everything chunk_index
31
+ yields, because it is the primary key in BOTH VectorStore._pos and
32
+ IntelService._chunk_hashes.
33
+
34
+ path+line-span alone is NOT unique: a symbol longer than
35
+ _CHUNK_MAX_LINES is split by `_windows` into windows that step by
36
+ (_WINDOW_LINES - _WINDOW_OVERLAP), and such a window can land on exactly
37
+ the span of a trailing nested symbol -- e.g. a class spanning 1..61 has
38
+ its last window at 51..61, and a method spanning 51..61 then produced the
39
+ SAME id ("path#51-61"). Two consequences, both silent:
40
+ * VectorStore.add took its "id already known" branch and wrote
41
+ _mat[pos] while _mat was still the empty (0, dim) array -> IndexError
42
+ -> boot.start_intel swallowed it -> code intel OFF for the session;
43
+ * _chunk_hashes collapsed both chunks onto one hash entry, so an
44
+ incremental re-embed could skip a genuinely changed chunk.
45
+ `kind` alone is not enough either: an outer symbol and a long nested one
46
+ can BOTH be windowed (kind="chunk" for each) and a window of each can
47
+ cover the same span -- observed on real dependency code, e.g. jinja2's
48
+ `Environment` vs its `__init__` both yielding "environment.py#294-353".
49
+ So the id carries `kind` AND `symbol`, which is what actually
50
+ distinguishes them. The id stays deterministic across runs -- stability
51
+ matters because apply_changes diffs chunk ids to decide what to re-embed.
52
+ """
30
53
  text = "\n".join(lines[s - 1:e])
31
- return Chunk(id=f"{path}#{s}-{e}", symbol=symbol, kind=kind, path=path,
54
+ return Chunk(id=f"{path}#{s}-{e}:{kind}:{symbol}", symbol=symbol, kind=kind, path=path,
32
55
  start_line=s, end_line=e, text=text, content_hash=_hash(text))
33
56
 
34
57
 
@@ -69,7 +69,15 @@ def _lexical_search(svc, q, kind, path_glob, items_by_id: dict) -> list[str]:
69
69
 
70
70
 
71
71
  def _chunk_item(svc, cid: str, score: float) -> dict | None:
72
- """Map an embed-chunk id ('<path>#<start>-<end>') back to a search item.
72
+ """Map an embed-chunk id back to a search item.
73
+
74
+ Id shape is '<path>#<start>-<end>:<kind>:<symbol>' (chunker._mk). The
75
+ ':<kind>:<symbol>' tail was added 2026-07-25 to make ids unique -- see
76
+ chunker._mk -- so the line range is only the part BEFORE the first ':'.
77
+ Ids written by an older cache have no tail at all, hence the partition
78
+ (not a required split): both shapes parse, and a schema bump means the
79
+ old ones are normally gone anyway.
80
+
73
81
  Title/kind = the enclosing symbol's when the graph has one covering the
74
82
  chunk's line range, else the bare '<path>:<start>' / 'chunk' fallback --
75
83
  a hardcoded kind="chunk" would make every vector hit invisible to a
@@ -77,6 +85,7 @@ def _chunk_item(svc, cid: str, score: float) -> dict | None:
77
85
  if "#" not in cid or "-" not in cid.rpartition("#")[2]:
78
86
  return None
79
87
  path, _, rng = cid.rpartition("#")
88
+ rng = rng.partition(":")[0] # drop the ':<kind>:<symbol>' tail
80
89
  start_s, end_s = rng.split("-", 1)
81
90
  try:
82
91
  start, end = int(start_s), int(end_s)
@@ -25,10 +25,39 @@ def _git_ref(root: str) -> str:
25
25
  return ""
26
26
 
27
27
 
28
+ # Dependency / build trees are pruned from the index walk. This is not only a
29
+ # speed win, it is a CORRECTNESS one: `_walk` stops at `limit`, so on a repo
30
+ # with a big in-tree dependency directory the cap could be spent on
31
+ # third-party files while the project's OWN sources went unindexed.
32
+ #
33
+ # Measured on this checkout (it carries an in-tree `venv/`): 3062 files -> 111
34
+ # candidates, and build_index 30.0s -> well under a second.
35
+ #
36
+ # ".git" and every DOTTED directory were already pruned (covering .venv/,
37
+ # .tox/, .mypy_cache/, ...). These are the undotted ones that were not.
38
+ # Deliberately conservative -- only names that are unambiguous dependency
39
+ # caches. Names that a real project might legitimately use for its OWN source
40
+ # ("build", "dist", "target", "env", "vendor") are NOT pruned by name here: a
41
+ # virtualenv called "venv"/"env" is instead detected by its `pyvenv.cfg`
42
+ # marker below, which is a fact about the directory rather than a guess about
43
+ # its name.
44
+ _SKIP_DIRS = frozenset({"node_modules", "site-packages", "__pycache__"})
45
+
46
+
47
+ def _is_virtualenv(path: str) -> bool:
48
+ """True for a real Python virtualenv root: PEP 405 puts `pyvenv.cfg` at
49
+ the top of every one. Checking the MARKER (not the folder name) means a
50
+ project module that merely happens to be called `env/` keeps being
51
+ indexed, while an in-tree `venv/` is skipped whatever it is named."""
52
+ return os.path.exists(os.path.join(path, "pyvenv.cfg"))
53
+
54
+
28
55
  def _walk(root: str, limit: int = 20000) -> list[str]:
29
56
  out = []
30
57
  for dp, dns, fns in os.walk(root):
31
- dns[:] = [d for d in dns if d != ".git" and not d.startswith(".") and d != "node_modules"]
58
+ dns[:] = [d for d in dns
59
+ if d != ".git" and not d.startswith(".") and d not in _SKIP_DIRS
60
+ and not _is_virtualenv(os.path.join(dp, d))]
32
61
  for fn in fns:
33
62
  out.append(os.path.relpath(os.path.join(dp, fn), root))
34
63
  if len(out) >= limit:
@@ -18,7 +18,12 @@ from webbee.intel.models import ProjectIndex, FileIndex, Symbol
18
18
  # extra) can still import this whole module (index.json save/load, the
19
19
  # graph plane) with no numpy on the system at all.
20
20
 
21
- SCHEMA_VERSION = 2
21
+ # 3 (2026-07-25): chunk ids gained a ":{kind}" suffix (chunker._mk) to fix a
22
+ # collision that silently disabled code intel. Every cached `ids` list from
23
+ # schema 2 holds the OLD id format, so both the index and the vector cache
24
+ # must be treated as a clean miss and rebuilt -- bumping the version is what
25
+ # makes load()/load_vectors() do exactly that.
26
+ SCHEMA_VERSION = 3
22
27
 
23
28
 
24
29
  def _path(cache_dir: str, repo_key: str) -> str:
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+ import numpy as np
3
+
4
+
5
+ def _normalize(m: np.ndarray) -> np.ndarray:
6
+ n = np.linalg.norm(m, axis=1, keepdims=True)
7
+ n[n == 0] = 1.0
8
+ return (m / n).astype(np.float32)
9
+
10
+
11
+ class VectorStore:
12
+ def __init__(self, dim: int) -> None:
13
+ self.dim = dim
14
+ self._ids: list[str] = []
15
+ self._pos: dict[str, int] = {}
16
+ self._mat = np.zeros((0, dim), dtype=np.float32)
17
+
18
+ def add(self, ids, vecs) -> None:
19
+ """Insert new ids and/or overwrite the rows of ids already present.
20
+
21
+ ORDER MATTERS: appends are materialised into `_mat` FIRST, overwrites
22
+ are applied only afterwards. Doing it the other way round (the pre-
23
+ 2026-07-25 shape) wrote `_mat[self._pos[_id]]` while the rows for this
24
+ very batch were still sitting in a pending `rows` list, so any id
25
+ whose position had already been assigned but not yet vstacked indexed
26
+ past the end of the array -- on a fresh store that is `_mat` with
27
+ shape (0, dim) and the write raised IndexError. `IntelService.
28
+ _embed_build` calls this once with the whole repo's chunk set and
29
+ `boot.start_intel` swallows exceptions, so that IndexError silently
30
+ disabled code intel for the entire session.
31
+
32
+ A duplicate id WITHIN one batch is now also handled coherently: the
33
+ first occurrence appends, later ones overwrite that same row (last
34
+ value wins) instead of exploding.
35
+ """
36
+ vecs = np.asarray(vecs, dtype=np.float32).reshape(-1, self.dim)
37
+ rows = []
38
+ updates = [] # (row_index, vec) -- applied after the append
39
+ for i, _id in enumerate(ids):
40
+ pos = self._pos.get(_id)
41
+ if pos is None:
42
+ self._pos[_id] = len(self._ids)
43
+ self._ids.append(_id)
44
+ rows.append(vecs[i])
45
+ else:
46
+ updates.append((pos, vecs[i]))
47
+ if rows:
48
+ new_rows = np.array(rows, dtype=np.float32)
49
+ self._mat = (np.vstack([self._mat, new_rows])
50
+ if self._mat.shape[0] else new_rows)
51
+ if updates:
52
+ if not self._mat.flags.writeable:
53
+ # Copy-on-write: _mat may be a read-only mmap loaded straight
54
+ # off the vector cache (store.load_vectors' mmap_mode="r" perf
55
+ # win) via from_arrays. A read-only boot+search session never
56
+ # reaches this branch; only an incremental re-embed of an
57
+ # existing chunk id (same id, changed content) needs to mutate
58
+ # a row in place, so pay for exactly one full copy here, lazily.
59
+ self._mat = np.array(self._mat)
60
+ for pos, vec in updates:
61
+ self._mat[pos] = vec
62
+
63
+ def remove(self, ids) -> None:
64
+ drop = {i for i in ids if i in self._pos}
65
+ if not drop:
66
+ return
67
+ keep = [(i, _id) for i, _id in enumerate(self._ids) if _id not in drop]
68
+ self._mat = self._mat[[i for i, _ in keep]] if keep else np.zeros((0, self.dim), dtype=np.float32)
69
+ self._ids = [_id for _, _id in keep]
70
+ self._pos = {_id: n for n, _id in enumerate(self._ids)}
71
+
72
+ def search(self, qvec, top_n: int):
73
+ if not self._ids:
74
+ return []
75
+ q = np.asarray(qvec, dtype=np.float32).reshape(self.dim)
76
+ qn = q / (np.linalg.norm(q) or 1.0)
77
+ sims = _normalize(self._mat) @ qn
78
+ n = min(top_n, len(self._ids))
79
+ idx = np.argpartition(-sims, n - 1)[:n]
80
+ idx = idx[np.argsort(-sims[idx])]
81
+ return [(self._ids[i], float(sims[i])) for i in idx]
82
+
83
+ def ids(self):
84
+ return list(self._ids)
85
+
86
+ def to_arrays(self):
87
+ return list(self._ids), self._mat.copy()
88
+
89
+ @classmethod
90
+ def from_arrays(cls, dim, ids, matrix):
91
+ vs = cls(dim)
92
+ vs._ids = list(ids)
93
+ vs._pos = {_id: n for n, _id in enumerate(vs._ids)}
94
+ vs._mat = np.asarray(matrix, dtype=np.float32).reshape(-1, dim)
95
+ return vs
@@ -28,9 +28,33 @@ from webbee.repo import compute_repo_key, find_repo_root
28
28
  _CACHE_DIR = os.path.expanduser("~/.cache/webbee") # test seam: monkeypatch this name
29
29
 
30
30
 
31
+ # repo_key is derived from `git remote get-url origin` (compute_repo_key,
32
+ # timeout=5) -- measured ~11ms per call on a warm local repo, worst case the
33
+ # full 5s on a repo whose git call hangs. It is also INVARIANT for the life of
34
+ # the process: a workspace's git remote does not change under a running dock.
35
+ # Every OTHER caller already keeps that subprocess off the event loop via
36
+ # asyncio.to_thread (repl.py's boot paths), but save_mode is reached
37
+ # SYNCHRONOUSLY from the Shift-TAB key binding (tui `_cycle` ->
38
+ # repl.set_slot_mode -> save_mode), i.e. straight on the event loop, where it
39
+ # froze the dock on every single mode switch.
40
+ #
41
+ # Memoising per workspace fixes that for every switch after the first: the
42
+ # repeat cost is a dict lookup. The first call in a process still pays, which
43
+ # is acceptable because boot's own load_mode already warms this cache off-loop
44
+ # before any key can be pressed.
45
+ _KEY_CACHE: dict[str, str] = {}
46
+
47
+
48
+ def _repo_key_for(workspace: str) -> str:
49
+ key = _KEY_CACHE.get(workspace)
50
+ if key is None:
51
+ key = compute_repo_key(find_repo_root(workspace))
52
+ _KEY_CACHE[workspace] = key
53
+ return key
54
+
55
+
31
56
  def _path_for(workspace: str) -> str:
32
- repo_key = compute_repo_key(find_repo_root(workspace))
33
- return os.path.join(_CACHE_DIR, f"mode-{repo_key}")
57
+ return os.path.join(_CACHE_DIR, f"mode-{_repo_key_for(workspace)}")
34
58
 
35
59
 
36
60
  def load_mode(workspace: str) -> "str | None":
@@ -65,6 +65,7 @@ class OutputPane:
65
65
  self._follow = True # stick to the tail unless the user scrolled up
66
66
  self._sel = None # (abs_start, abs_end) during a drag → live highlight
67
67
  self._edge_drag = 0 # +1/-1 while dragging at the bottom/top edge, else 0
68
+ self._resize_pending = 0 # width seen but not yet reflowed (tui._width_watch debounce)
68
69
  self._edge_ticks = 0 # consecutive edge_tick()s since the last drag MOUSE_MOVE
69
70
  self._plain_cache = (0, [""]) # (write-pos, ANSI-stripped lines) for select/highlight
70
71
  self.copy_flash = "" # transient toolbar note after a copy
@@ -200,6 +200,12 @@ def set_slot_mode(slot: SessionSlot, mode: str) -> None:
200
200
  save_mode(slot.workspace, mode)
201
201
 
202
202
 
203
+ # Strong refs for in-flight autopilot confirms (see _on_mode). A module-level
204
+ # set is safe here: entries are removed by their own done-callback, and it is
205
+ # never read as state -- purely GC ballast.
206
+ _CONFIRM_TASKS: set = set()
207
+
208
+
203
209
  def _on_mode(slot: SessionSlot, mode: str, surface: str) -> None:
204
210
  """Remote coding-mode request (TG/panel → gateway one-shot req_mode →
205
211
  the pending-steer poll). AUTOPILOT SAFE ASYMMETRY (Valentin-chosen): a
@@ -225,7 +231,19 @@ def _on_mode(slot: SessionSlot, mode: str, surface: str) -> None:
225
231
  set_slot_mode(slot, mode)
226
232
  _say(slot, f"mode → {mode} [{surface}]")
227
233
  return
228
- asyncio.ensure_future(_confirm_autopilot(slot, surface))
234
+ # Keep a STRONG reference until the confirm finishes: asyncio only holds a
235
+ # weak one, so a bare ensure_future() can be garbage-collected mid-await
236
+ # and the autopilot prompt would vanish silently, leaving the user's remote
237
+ # upgrade request unanswered with no trace in the transcript.
238
+ #
239
+ # Deliberately NOT slot.bg_tasks: everything parked there is cancelled by
240
+ # the slot-close/exit teardown, and this confirm must be allowed to COMPLETE
241
+ # (the local yes/no is the security gate for the whole upgrade -- see
242
+ # _confirm_autopilot). The task removes itself via the done callback, so the
243
+ # set cannot grow without bound.
244
+ _t = asyncio.ensure_future(_confirm_autopilot(slot, surface))
245
+ _CONFIRM_TASKS.add(_t)
246
+ _t.add_done_callback(_CONFIRM_TASKS.discard)
229
247
 
230
248
 
231
249
  async def _confirm_autopilot(slot: SessionSlot, surface: str) -> None:
@@ -201,26 +201,54 @@ def _width_watch(pane, app) -> None:
201
201
  SIGWINCH by itself, but the RICH side (console width) must be told to
202
202
  re-wrap — this is the bridge. Two int compares when nothing changed.
203
203
  Swallows any reflow error — the ticker is the dock's only animation
204
- loop (spinner + queue drains ride on it too) and must never die."""
204
+ loop (spinner + queue drains ride on it too) and must never die.
205
+
206
+ DEBOUNCED (2026-07-25): `pane.reflow` re-renders the whole retained ring,
207
+ which is linear in records (~29µs each — ~116ms at the 4000-record cap)
208
+ and runs ON the event loop, so the dock is frozen for its whole duration.
209
+ Reflowing on the FIRST tick that sees a new width meant a slow drag-resize
210
+ paid that cost once per intermediate width the ticker happened to sample
211
+ (measured: 5 samples ≈ 535ms of cumulative freeze) — and every one of
212
+ those reflows was thrown away by the next sample anyway.
213
+
214
+ So a changed width is only REMEMBERED on the tick that first sees it; the
215
+ reflow fires on the next tick that reads the SAME width, i.e. once the
216
+ user stops dragging. `_resize_pending` lives on the pane (not module
217
+ state) so each tab debounces its own resize independently, and
218
+ `_ticker_busy` treats a pending resize as busy so the settle happens at
219
+ the fast 0.25s cadence instead of the 1.0s idle one."""
205
220
  from webbee.sizing import get_size
206
221
  cols, _rows = get_size(app)
207
- if cols and cols != pane.console.width:
222
+ if not cols:
223
+ return
224
+ pending = getattr(pane, "_resize_pending", 0)
225
+ if cols != pane.console.width:
226
+ if cols != pending:
227
+ pane._resize_pending = cols # still moving — settle next tick
228
+ return
229
+ pane._resize_pending = 0
208
230
  try:
209
231
  pane.reflow(cols)
210
232
  except Exception:
211
233
  pass
234
+ elif pending:
235
+ pane._resize_pending = 0 # snapped back to the current width
212
236
 
213
237
 
214
238
  def _ticker_busy(slots, is_busy) -> bool:
215
239
  """Whether the dock's animation loop must stay at the FAST (0.25s) cadence:
216
- a turn is running, a copy-flash toast is live, OR an edge-drag auto-scroll
217
- is in flight (`pane._edge_drag` — a drag-select past the viewport edge that
218
- `pane.edge_tick()` keeps scrolling every tick). All three need smooth ~4x/s
219
- updates; miss the edge-drag one and idle drag-scrolling crawls 4x slower.
220
- Otherwise the loop is idle the caller uses the slow 1.0s cadence."""
240
+ a turn is running, a copy-flash toast is live, an edge-drag auto-scroll is
241
+ in flight (`pane._edge_drag` — a drag-select past the viewport edge that
242
+ `pane.edge_tick()` keeps scrolling every tick), OR a debounced resize is
243
+ waiting to settle (`pane._resize_pending`, see `_width_watch`). All of them
244
+ need smooth ~4x/s updates; miss the edge-drag one and idle drag-scrolling
245
+ crawls 4x slower, miss the resize one and a re-wrap after a drag lands up
246
+ to a full second late. Otherwise the loop is idle → the caller uses the
247
+ slow 1.0s cadence."""
221
248
  try:
222
249
  pane = slots.active().pane
223
- if bool(pane.flash()) or bool(getattr(pane, "_edge_drag", 0)):
250
+ if (bool(pane.flash()) or bool(getattr(pane, "_edge_drag", 0))
251
+ or bool(getattr(pane, "_resize_pending", 0))):
224
252
  return True
225
253
  except Exception:
226
254
  pass
@@ -865,18 +893,31 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
865
893
  pane.flash_note("📎 open a session tab to paste a file")
866
894
  event.app.invalidate()
867
895
  return
868
- item = read_clipboard(_t.strftime("%Y%m%d-%H%M%S", _t.gmtime()))
869
- if item is None:
870
- pane.flash_note("clipboard is empty")
871
- event.app.invalidate()
872
- return
873
- if item.kind == "text":
874
- buf.insert_text(item.data)
875
- return
876
- pane.flash_note(f"📎 uploading {item.name}…", secs=30.0)
877
- event.app.invalidate()
896
+ # read_clipboard() shells out to the platform clipboard tool
897
+ # (osascript+pbpaste on macOS, xclip/wl-paste on Linux) with a 2s
898
+ # timeout EACH. Measured on this host: 83.7ms for a plain-text
899
+ # clipboard (73.5 osascript + 10.1 pbpaste), and up to ~4s if both
900
+ # timeouts are hit. Running that inline froze the whole dock -- no
901
+ # repaint, no spinner, no keystrokes -- for the duration, because a
902
+ # key binding runs ON the event loop. So it moves to a worker thread;
903
+ # `slot`/`pane` are already captured synchronously above (same
904
+ # discipline as _run_turn), so the paste lands on the tab that was
905
+ # active when the key was pressed even if the user switches tabs
906
+ # mid-read.
907
+ stamp = _t.strftime("%Y%m%d-%H%M%S", _t.gmtime())
878
908
 
879
909
  async def _do_paste():
910
+ item = await asyncio.to_thread(read_clipboard, stamp)
911
+ if item is None:
912
+ pane.flash_note("clipboard is empty")
913
+ event.app.invalidate()
914
+ return
915
+ if item.kind == "text":
916
+ buf.insert_text(item.data)
917
+ event.app.invalidate()
918
+ return
919
+ pane.flash_note(f"📎 uploading {item.name}…", secs=30.0)
920
+ event.app.invalidate()
880
921
  ref = ""
881
922
  try:
882
923
  ref = await on_paste(slot.workspace, item.name, item.data)
@@ -37,3 +37,21 @@ def _isolate_newtab_mode_cache(tmp_path, monkeypatch):
37
37
  real cache; keep every test hermetic)."""
38
38
  import webbee.newtab_mode as newtab_mode
39
39
  monkeypatch.setattr(newtab_mode, "_CACHE_DIR", str(tmp_path / "webbee-newtab-cache"))
40
+
41
+
42
+ @pytest.fixture(autouse=True)
43
+ def _reset_mode_store_repo_key_cache():
44
+ """2026-07-25: `mode_store._KEY_CACHE` memoises repo_key per workspace so
45
+ Shift-TAB stops paying for a `git remote get-url` on every mode switch
46
+ (~13ms -> ~0.2ms). It is process-global by design -- a workspace's git
47
+ remote cannot change under a running dock -- but in a TEST process it
48
+ would outlive the test that filled it, so a later test that patches
49
+ `compute_repo_key` for the SAME workspace path would silently read the
50
+ earlier test's key. Today every test uses a `tmp_path`-unique workspace,
51
+ so nothing collides; clearing it around each test makes that hold BY
52
+ CONSTRUCTION instead of by luck.
53
+ """
54
+ import webbee.mode_store as mode_store
55
+ mode_store._KEY_CACHE.clear()
56
+ yield
57
+ mode_store._KEY_CACHE.clear()