webbee 0.3.34__tar.gz → 0.3.36__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 (125) hide show
  1. {webbee-0.3.34 → webbee-0.3.36}/CHANGELOG.md +48 -0
  2. {webbee-0.3.34 → webbee-0.3.36}/PKG-INFO +1 -1
  3. {webbee-0.3.34 → webbee-0.3.36}/pyproject.toml +1 -1
  4. webbee-0.3.36/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/commands.py +6 -1
  6. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/home.py +8 -4
  7. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/home_view.py +104 -4
  8. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/intel/chunker.py +24 -1
  9. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/intel/query.py +10 -1
  10. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/intel/service.py +30 -1
  11. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/intel/store.py +6 -1
  12. webbee-0.3.36/src/webbee/intel/vectors.py +95 -0
  13. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/mode_store.py +26 -2
  14. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/output_pane.py +1 -0
  15. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/repl.py +108 -12
  16. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/tui.py +174 -29
  17. webbee-0.3.36/src/webbee/update.py +82 -0
  18. {webbee-0.3.34 → webbee-0.3.36}/tests/conftest.py +18 -0
  19. webbee-0.3.36/tests/test_audit_2026_07_25_findings.py +479 -0
  20. {webbee-0.3.34 → webbee-0.3.36}/tests/test_intel_chunker.py +3 -1
  21. webbee-0.3.36/tests/test_terminal_ux_0336.py +481 -0
  22. {webbee-0.3.34 → webbee-0.3.36}/tests/test_tui.py +27 -9
  23. webbee-0.3.34/src/webbee/__init__.py +0 -1
  24. webbee-0.3.34/src/webbee/intel/vectors.py +0 -72
  25. webbee-0.3.34/src/webbee/update.py +0 -52
  26. {webbee-0.3.34 → webbee-0.3.36}/.github/workflows/publish.yml +0 -0
  27. {webbee-0.3.34 → webbee-0.3.36}/.gitignore +0 -0
  28. {webbee-0.3.34 → webbee-0.3.36}/LICENSE +0 -0
  29. {webbee-0.3.34 → webbee-0.3.36}/README.md +0 -0
  30. {webbee-0.3.34 → webbee-0.3.36}/docs/superpowers/plans/2026-07-21-interactive-home-dashboard.md +0 -0
  31. {webbee-0.3.34 → webbee-0.3.36}/install.sh +0 -0
  32. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/account.py +0 -0
  33. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/active_sessions.py +0 -0
  34. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/banner_art.py +0 -0
  35. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/boot.py +0 -0
  36. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/checkpoints.py +0 -0
  37. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/cli.py +0 -0
  38. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/clipboard.py +0 -0
  39. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/clipboard_read.py +0 -0
  40. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/coding_context.py +0 -0
  41. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/config.py +0 -0
  42. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/consent.py +0 -0
  43. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/details.py +0 -0
  44. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/events.py +0 -0
  45. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/frames.py +0 -0
  46. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/http.py +0 -0
  47. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/instance_lock.py +0 -0
  48. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/intel/__init__.py +0 -0
  49. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/intel/embed.py +0 -0
  50. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/intel/graph.py +0 -0
  51. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/intel/indexer.py +0 -0
  52. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/intel/models.py +0 -0
  53. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/intel/watch.py +0 -0
  54. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/newtab_mode.py +0 -0
  55. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/queue_panel.py +0 -0
  56. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/reflow.py +0 -0
  57. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/remote.py +0 -0
  58. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/render.py +0 -0
  59. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/repo.py +0 -0
  60. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/selection.py +0 -0
  61. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/session.py +0 -0
  62. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/sessions.py +0 -0
  63. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/sizing.py +0 -0
  64. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/slots.py +0 -0
  65. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/steer.py +0 -0
  66. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/stream.py +0 -0
  67. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/tabs.py +0 -0
  68. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/thread.py +0 -0
  69. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/todo_panel.py +0 -0
  70. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/tokens.py +0 -0
  71. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/tools.py +0 -0
  72. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/urlopen.py +0 -0
  73. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/wallet.py +0 -0
  74. {webbee-0.3.34 → webbee-0.3.36}/src/webbee/worktrees.py +0 -0
  75. {webbee-0.3.34 → webbee-0.3.36}/tests/__init__.py +0 -0
  76. {webbee-0.3.34 → webbee-0.3.36}/tests/test_account.py +0 -0
  77. {webbee-0.3.34 → webbee-0.3.36}/tests/test_active_sessions.py +0 -0
  78. {webbee-0.3.34 → webbee-0.3.36}/tests/test_checkpoints.py +0 -0
  79. {webbee-0.3.34 → webbee-0.3.36}/tests/test_cli.py +0 -0
  80. {webbee-0.3.34 → webbee-0.3.36}/tests/test_clipboard.py +0 -0
  81. {webbee-0.3.34 → webbee-0.3.36}/tests/test_clipboard_read.py +0 -0
  82. {webbee-0.3.34 → webbee-0.3.36}/tests/test_coding_context.py +0 -0
  83. {webbee-0.3.34 → webbee-0.3.36}/tests/test_commands.py +0 -0
  84. {webbee-0.3.34 → webbee-0.3.36}/tests/test_config.py +0 -0
  85. {webbee-0.3.34 → webbee-0.3.36}/tests/test_cpc_contract_stable.py +0 -0
  86. {webbee-0.3.34 → webbee-0.3.36}/tests/test_details.py +0 -0
  87. {webbee-0.3.34 → webbee-0.3.36}/tests/test_events.py +0 -0
  88. {webbee-0.3.34 → webbee-0.3.36}/tests/test_freeze_fix.py +0 -0
  89. {webbee-0.3.34 → webbee-0.3.36}/tests/test_home.py +0 -0
  90. {webbee-0.3.34 → webbee-0.3.36}/tests/test_home_view.py +0 -0
  91. {webbee-0.3.34 → webbee-0.3.36}/tests/test_instance_lock.py +0 -0
  92. {webbee-0.3.34 → webbee-0.3.36}/tests/test_intel_embed.py +0 -0
  93. {webbee-0.3.34 → webbee-0.3.36}/tests/test_intel_graph.py +0 -0
  94. {webbee-0.3.34 → webbee-0.3.36}/tests/test_intel_indexer.py +0 -0
  95. {webbee-0.3.34 → webbee-0.3.36}/tests/test_intel_no_numpy.py +0 -0
  96. {webbee-0.3.34 → webbee-0.3.36}/tests/test_intel_query.py +0 -0
  97. {webbee-0.3.34 → webbee-0.3.36}/tests/test_intel_service.py +0 -0
  98. {webbee-0.3.34 → webbee-0.3.36}/tests/test_intel_store.py +0 -0
  99. {webbee-0.3.34 → webbee-0.3.36}/tests/test_intel_vectors.py +0 -0
  100. {webbee-0.3.34 → webbee-0.3.36}/tests/test_intel_watch.py +0 -0
  101. {webbee-0.3.34 → webbee-0.3.36}/tests/test_marathon.py +0 -0
  102. {webbee-0.3.34 → webbee-0.3.36}/tests/test_mode_store.py +0 -0
  103. {webbee-0.3.34 → webbee-0.3.36}/tests/test_newtab_mode.py +0 -0
  104. {webbee-0.3.34 → webbee-0.3.36}/tests/test_packaging.py +0 -0
  105. {webbee-0.3.34 → webbee-0.3.36}/tests/test_reflow.py +0 -0
  106. {webbee-0.3.34 → webbee-0.3.36}/tests/test_render.py +0 -0
  107. {webbee-0.3.34 → webbee-0.3.36}/tests/test_repl.py +0 -0
  108. {webbee-0.3.34 → webbee-0.3.36}/tests/test_repo.py +0 -0
  109. {webbee-0.3.34 → webbee-0.3.36}/tests/test_session.py +0 -0
  110. {webbee-0.3.34 → webbee-0.3.36}/tests/test_sessions.py +0 -0
  111. {webbee-0.3.34 → webbee-0.3.36}/tests/test_sizing.py +0 -0
  112. {webbee-0.3.34 → webbee-0.3.36}/tests/test_slots.py +0 -0
  113. {webbee-0.3.34 → webbee-0.3.36}/tests/test_steer.py +0 -0
  114. {webbee-0.3.34 → webbee-0.3.36}/tests/test_stream.py +0 -0
  115. {webbee-0.3.34 → webbee-0.3.36}/tests/test_tabs.py +0 -0
  116. {webbee-0.3.34 → webbee-0.3.36}/tests/test_thread.py +0 -0
  117. {webbee-0.3.34 → webbee-0.3.36}/tests/test_todo_panel.py +0 -0
  118. {webbee-0.3.34 → webbee-0.3.36}/tests/test_tokens.py +0 -0
  119. {webbee-0.3.34 → webbee-0.3.36}/tests/test_tools.py +0 -0
  120. {webbee-0.3.34 → webbee-0.3.36}/tests/test_tui_hardening.py +0 -0
  121. {webbee-0.3.34 → webbee-0.3.36}/tests/test_update.py +0 -0
  122. {webbee-0.3.34 → webbee-0.3.36}/tests/test_urlopen.py +0 -0
  123. {webbee-0.3.34 → webbee-0.3.36}/tests/test_version.py +0 -0
  124. {webbee-0.3.34 → webbee-0.3.36}/tests/test_wallet.py +0 -0
  125. {webbee-0.3.34 → webbee-0.3.36}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.36
4
+
5
+ Write on more than one line, and see what a session actually costs.
6
+
7
+ - **New line inside your prompt.** Press **Alt+Enter** — or **Shift+Enter** on
8
+ a modern terminal, or **Ctrl+J** anywhere at all — to add a line without
9
+ sending. Enter still sends, exactly as before. Three chords because no single
10
+ one is available in every terminal: whichever your setup supports, one of
11
+ them works. Continuation lines are marked with a dim ┊ gutter, so a
12
+ half-written message never looks like something already sent. Pasting
13
+ multi-line text now keeps its line breaks instead of firing off each line.
14
+ - **Spend is visible from every tab.** The status line at the bottom shows the
15
+ tokens and credits used in this session, in every tab. Home — which has no
16
+ session of its own — shows the running TOTAL across all your open tabs, both
17
+ in its status line and in the Wallet tile ("spent this session").
18
+ - **Version and freshness, bottom right of Home.** Your Webbee version sits in
19
+ the bottom-right corner. It says "up to date" only when we actually managed
20
+ to check; when we could not reach the release server it shows the plain
21
+ version rather than a reassurance we cannot back up. A newer release is
22
+ called out in bee-yellow with the version to upgrade to.
23
+ - **`/login` now works on Home.** Previously `/login`, `/logout`, `/sessions`,
24
+ `/logout-others` and `/cost` only worked in a session tab, which made no
25
+ sense — they act on your account, not on a session. They all work on Home
26
+ now, and Home shows a "Sign in to Imperal" button when you are signed out
27
+ instead of a blank space. `/cost` on Home reports the whole session's total.
28
+ - `/help` lists the keyboard shortcuts, including the new-line chords.
29
+
30
+ ## 0.3.35
31
+
32
+ Reliability and speed of the terminal itself.
33
+
34
+ - Code intelligence no longer dies silently at startup. On many repositories it
35
+ failed while building its index and the terminal simply said "code intel off"
36
+ with no reason — semantic search and the code graph were quietly unavailable
37
+ for the whole session. It now builds correctly (and old broken caches are
38
+ rebuilt automatically).
39
+ - Resizing the window is smooth again: dragging a split no longer re-wraps the
40
+ text on every intermediate width, so the terminal stops freezing mid-drag.
41
+ - Ctrl+V no longer blocks the terminal while reading the clipboard, so pasting
42
+ a large image can't lock up the interface.
43
+ - Switching tabs with Shift+TAB is instant again — it no longer runs a `git`
44
+ command each time (which could stall if git itself hung).
45
+ - Building the project index skips virtualenvs and dependency caches, so a
46
+ large in-tree environment can no longer crowd out your own source files.
47
+ On this repository the full pass went from ~31s to ~1s.
48
+ - Fixed a rare case where a remote approval prompt could vanish before you
49
+ answered it.
50
+
3
51
  ## 0.3.34
4
52
 
5
53
  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.36
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.36"
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.36"
@@ -23,7 +23,12 @@ _HELP = """Commands:
23
23
  /sessions revoke <#> revoke a session by its number
24
24
  /logout-others sign out every session except this one
25
25
  /notify [tg|panel|both|off] mirror this session to Telegram/panel & steer it back
26
- /exit (=/quit) quit"""
26
+ /exit (=/quit) quit
27
+
28
+ Keys:
29
+ Alt+↵ (or Shift+↵, or Ctrl+J) new line in the prompt — Enter still sends
30
+ Shift+TAB switch mode Ctrl+T new tab
31
+ Alt+№ switch to tab № Ctrl+W close tab"""
27
32
 
28
33
 
29
34
  @dataclass(frozen=True)
@@ -181,13 +181,17 @@ async def fill_home(slot, *, cfg, token_provider, slots, account_fetcher,
181
181
  try:
182
182
  from pathlib import Path
183
183
 
184
- from webbee.update import check_for_update, default_fetch
184
+ from webbee.update import check_update_status, default_fetch
185
185
  cache = Path(os.path.expanduser("~/.cache/webbee/update.json"))
186
- data.update_notice = await asyncio.to_thread(
187
- check_for_update, version, cache_path=cache, now=time.time(),
188
- fetch=default_fetch) or ""
186
+ # 0.3.36: the (notice, checked) variant -- Home's version badge
187
+ # only claims "up to date" when the check actually resolved a
188
+ # latest version; offline shows the bare version, no false claim.
189
+ data.update_notice, data.update_checked = await asyncio.to_thread(
190
+ check_update_status, version, cache_path=cache, now=time.time(),
191
+ fetch=default_fetch)
189
192
  except Exception:
190
193
  data.update_notice = ""
194
+ data.update_checked = False
191
195
  _repaint()
192
196
  finally:
193
197
  slot._last_fill = time.monotonic()
@@ -64,6 +64,9 @@ class HomeData:
64
64
  endpoint: str = ""
65
65
  version: str = ""
66
66
  update_notice: str = ""
67
+ # 0.3.36: did the freshness check actually COMPLETE? False = we could not
68
+ # reach PyPI (offline/timeout), so the footer must not claim "up to date".
69
+ update_checked: bool = False
67
70
  notice: str = "" # transient one-line note (top-up/security URL)
68
71
 
69
72
 
@@ -90,6 +93,11 @@ class HomeActions:
90
93
  set_new_tab_mode: "Callable[[str], None]"
91
94
  top_up: "Callable[[], None]"
92
95
  open_security_docs: "Callable[[], None]"
96
+ # 0.3.36 -- signing in is now possible FROM Home (/login was un-gated), so
97
+ # the dashboard offers it as a real action instead of leaving a bare "…"
98
+ # where the account should be. Optional: None -> the item is not offered
99
+ # (every pre-0.3.36 caller/test keeps working unchanged).
100
+ sign_in: "Callable[[], None] | None" = None
93
101
 
94
102
 
95
103
  class HomeModel:
@@ -159,11 +167,64 @@ def _cycle(options, current: str, delta: int) -> str:
159
167
  return options[(i + delta) % len(options)]
160
168
 
161
169
 
170
+ def version_badge(version: str, update_notice: str = "", checked: bool = True) -> "tuple[str, str]":
171
+ """(text, style_class) for Home's bottom-right version badge (0.3.36).
172
+
173
+ THREE honest states, never a fake reassurance:
174
+ * a newer release is known -> "v0.3.35 → 0.3.36 available" (accent)
175
+ * checked, nothing newer -> "v0.3.36 · up to date" (dim)
176
+ * not checked (offline/first run, `checked=False`) -> "v0.3.36"
177
+ with NO freshness claim at all -- we do not say "up to date" when we
178
+ could not reach PyPI.
179
+
180
+ `update_notice` is `update.check_for_update`'s own sentence; the newer
181
+ version is parsed out of it for the compact badge and the full upgrade
182
+ command stays in the Settings tile, so the footer never wraps.
183
+ """
184
+ v = (version or "").strip()
185
+ vtxt = f"v{v}" if v else "v?"
186
+ notice = (update_notice or "").strip()
187
+ if notice:
188
+ latest = ""
189
+ for tok in notice.replace("—", " ").split():
190
+ if tok.startswith("v") and tok[1:2].isdigit():
191
+ latest = tok[1:]
192
+ return ((f"{vtxt} → {latest} available" if latest else f"{vtxt} · update available"),
193
+ "class:home.update")
194
+ if checked:
195
+ return (f"{vtxt} · up to date", "class:home.fresh")
196
+ return (vtxt, "class:home.dim")
197
+
198
+
162
199
  def two_column(width: int, threshold: int = 100) -> bool:
163
200
  """Wide terminal -> You + Wallet render side-by-side; narrow -> stacked."""
164
201
  return width >= threshold
165
202
 
166
203
 
204
+ def session_totals(slots) -> "tuple[int, int]":
205
+ """(tokens, credits) summed across EVERY open session tab (0.3.36).
206
+
207
+ Home has no sink of its own, so "what has this session cost me?" can only
208
+ be answered by adding up the live per-tab counters. Pure + total: any slot
209
+ whose sink is missing or whose status() misbehaves contributes 0 rather
210
+ than breaking the toolbar, and Home's own sink-less slot is skipped
211
+ naturally (no sink -> no numbers). Reuses tab_rows so the numbers on Home
212
+ and in the toolbar can never disagree.
213
+ """
214
+ tokens = credits = 0
215
+ try:
216
+ rows = tab_rows(slots)
217
+ except Exception:
218
+ return (0, 0)
219
+ for r in rows:
220
+ try:
221
+ tokens += int(r.tokens or 0)
222
+ credits += int(r.credits or 0)
223
+ except (TypeError, ValueError):
224
+ pass
225
+ return (tokens, credits)
226
+
227
+
167
228
  def tab_rows(slots) -> "list[TabRow]":
168
229
  """PURE transform of the live SlotManager into Home's per-tab rows
169
230
  (session tabs only -- Home never lists itself). Per-tab spend comes from
@@ -197,6 +258,16 @@ def build_home_model(data: "HomeData", tabs: "list[TabRow]",
197
258
  enabled (they need no live session)."""
198
259
  items: "list[ActionItem]" = []
199
260
 
261
+ # Signed out? Then signing in is THE next step -- offer it first, before
262
+ # anything that would just fail without an account (0.3.36).
263
+ acct = getattr(data, "account", None)
264
+ signed_in = bool(getattr(acct, "signed_in", False)) if acct is not None else False
265
+ if not signed_in and actions.sign_in is not None:
266
+ items.append(ActionItem(
267
+ id="sign-in", label="→ Sign in to Imperal",
268
+ hint="sign in with your browser (same as typing /login)",
269
+ activate=actions.sign_in))
270
+
200
271
  items.append(ActionItem(
201
272
  id="new-session", label="+ New session",
202
273
  hint="open a new session tab (Ctrl+T)",
@@ -489,6 +560,13 @@ class HomeView:
489
560
  a = self.data.account
490
561
  L = [hdr("You")]
491
562
  if a is None or not getattr(a, "signed_in", False):
563
+ # Not signed in (or not fetched yet): say so plainly and offer
564
+ # the fix right here (0.3.36) instead of an opaque "…".
565
+ if "sign-in" in by_id:
566
+ it = by_id["sign-in"]
567
+ return L + [[("class:home.dim", " not signed in")],
568
+ [("class:home.dim", " "), act(it, "→ Sign in to Imperal")],
569
+ [("class:home.dim", " or type /login")]]
492
570
  return L + [[("class:home.dim", " …")]]
493
571
  nick = getattr(a, "nickname", "") or ""
494
572
  L.append([("class:home.value", f" @{nick}" if nick else " (no nickname)")])
@@ -516,7 +594,16 @@ class HomeView:
516
594
  L.append([("class:home.dim", " credits —")])
517
595
  else:
518
596
  cap = f" / {_fmt_tokens(w.cap)}" if w.cap else ""
519
- L.append([("class:home.value", f" {_fmt_tokens(w.balance)}{cap} credits")])
597
+ L.append([("class:home.value", f" {_fmt_tokens(w.balance)}{cap} credits"),
598
+ ("class:home.dim", " left")])
599
+ # This session's TOTAL spend across every open tab (0.3.36) --
600
+ # Home is the summary page, so the figure belongs here and not
601
+ # only in each tab's own toolbar. Same `session_totals` the
602
+ # toolbar uses, so the two can never disagree.
603
+ stk, scr = session_totals(self.slots)
604
+ L.append([("class:home.dim", " spent this session "),
605
+ ("class:home.spend", f"{_fmt_tokens(scr)} credits"),
606
+ ("class:home.dim", f" ({_fmt_tokens(stk)} tokens)")])
520
607
  it = by_id["top-up"]
521
608
  L.append([("class:home.dim", " "), act(it, "Top up credits")])
522
609
  return L
@@ -594,9 +681,22 @@ class HomeView:
594
681
  L = [[("", "")], [("class:home.hint", f" {hint}")]]
595
682
  if self.data.notice:
596
683
  L.append([("class:home.dim", f" {self.data.notice}")])
597
- L.append([("class:home.dim",
598
- " ↑↓ move · open · ←→ change "
599
- Ctrl+T new tab · Alt+N switch")])
684
+ # Key legend on the LEFT, version badge hard-right on the SAME row
685
+ # (0.3.36) -- the bottom-right corner of Home, as asked. When the
686
+ # window is too narrow to hold both, the badge drops to its own
687
+ # right-aligned row instead of being truncated.
688
+ keys = (" ↑↓ move · ↵ open · ←→ change "
689
+ "· Alt+↵ newline · Ctrl+T new tab · Alt+N switch")
690
+ btxt, bstyle = version_badge(self.data.version, self.data.update_notice,
691
+ checked=self.data.update_checked)
692
+ pad = width - len(keys) - len(btxt) - 2
693
+ if pad >= 1:
694
+ L.append([("class:home.dim", keys), ("", " " * pad),
695
+ (bstyle, btxt), ("", " ")])
696
+ else:
697
+ L.append([("class:home.dim", keys)])
698
+ rpad = max(0, width - len(btxt) - 2)
699
+ L.append([("", " " * rpad), (bstyle, btxt), ("", " ")])
600
700
  return L
601
701
 
602
702
  # No center title — the tab bar's ◆ Home chip already names the tab,
@@ -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