webbee 0.3.35__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 (124) hide show
  1. {webbee-0.3.35 → webbee-0.3.36}/CHANGELOG.md +27 -0
  2. {webbee-0.3.35 → webbee-0.3.36}/PKG-INFO +1 -1
  3. {webbee-0.3.35 → webbee-0.3.36}/pyproject.toml +1 -1
  4. webbee-0.3.36/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/commands.py +6 -1
  6. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/home.py +8 -4
  7. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/home_view.py +104 -4
  8. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/repl.py +89 -11
  9. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/tui.py +115 -11
  10. webbee-0.3.36/src/webbee/update.py +82 -0
  11. webbee-0.3.36/tests/test_terminal_ux_0336.py +481 -0
  12. webbee-0.3.35/src/webbee/__init__.py +0 -1
  13. webbee-0.3.35/src/webbee/update.py +0 -52
  14. {webbee-0.3.35 → webbee-0.3.36}/.github/workflows/publish.yml +0 -0
  15. {webbee-0.3.35 → webbee-0.3.36}/.gitignore +0 -0
  16. {webbee-0.3.35 → webbee-0.3.36}/LICENSE +0 -0
  17. {webbee-0.3.35 → webbee-0.3.36}/README.md +0 -0
  18. {webbee-0.3.35 → webbee-0.3.36}/docs/superpowers/plans/2026-07-21-interactive-home-dashboard.md +0 -0
  19. {webbee-0.3.35 → webbee-0.3.36}/install.sh +0 -0
  20. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/account.py +0 -0
  21. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/active_sessions.py +0 -0
  22. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/banner_art.py +0 -0
  23. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/boot.py +0 -0
  24. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/checkpoints.py +0 -0
  25. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/cli.py +0 -0
  26. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/clipboard.py +0 -0
  27. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/clipboard_read.py +0 -0
  28. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/coding_context.py +0 -0
  29. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/config.py +0 -0
  30. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/consent.py +0 -0
  31. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/details.py +0 -0
  32. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/events.py +0 -0
  33. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/frames.py +0 -0
  34. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/http.py +0 -0
  35. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/instance_lock.py +0 -0
  36. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/__init__.py +0 -0
  37. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/chunker.py +0 -0
  38. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/embed.py +0 -0
  39. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/graph.py +0 -0
  40. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/indexer.py +0 -0
  41. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/models.py +0 -0
  42. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/query.py +0 -0
  43. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/service.py +0 -0
  44. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/store.py +0 -0
  45. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/vectors.py +0 -0
  46. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/watch.py +0 -0
  47. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/mode_store.py +0 -0
  48. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/newtab_mode.py +0 -0
  49. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/output_pane.py +0 -0
  50. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/queue_panel.py +0 -0
  51. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/reflow.py +0 -0
  52. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/remote.py +0 -0
  53. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/render.py +0 -0
  54. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/repo.py +0 -0
  55. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/selection.py +0 -0
  56. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/session.py +0 -0
  57. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/sessions.py +0 -0
  58. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/sizing.py +0 -0
  59. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/slots.py +0 -0
  60. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/steer.py +0 -0
  61. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/stream.py +0 -0
  62. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/tabs.py +0 -0
  63. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/thread.py +0 -0
  64. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/todo_panel.py +0 -0
  65. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/tokens.py +0 -0
  66. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/tools.py +0 -0
  67. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/urlopen.py +0 -0
  68. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/wallet.py +0 -0
  69. {webbee-0.3.35 → webbee-0.3.36}/src/webbee/worktrees.py +0 -0
  70. {webbee-0.3.35 → webbee-0.3.36}/tests/__init__.py +0 -0
  71. {webbee-0.3.35 → webbee-0.3.36}/tests/conftest.py +0 -0
  72. {webbee-0.3.35 → webbee-0.3.36}/tests/test_account.py +0 -0
  73. {webbee-0.3.35 → webbee-0.3.36}/tests/test_active_sessions.py +0 -0
  74. {webbee-0.3.35 → webbee-0.3.36}/tests/test_audit_2026_07_25_findings.py +0 -0
  75. {webbee-0.3.35 → webbee-0.3.36}/tests/test_checkpoints.py +0 -0
  76. {webbee-0.3.35 → webbee-0.3.36}/tests/test_cli.py +0 -0
  77. {webbee-0.3.35 → webbee-0.3.36}/tests/test_clipboard.py +0 -0
  78. {webbee-0.3.35 → webbee-0.3.36}/tests/test_clipboard_read.py +0 -0
  79. {webbee-0.3.35 → webbee-0.3.36}/tests/test_coding_context.py +0 -0
  80. {webbee-0.3.35 → webbee-0.3.36}/tests/test_commands.py +0 -0
  81. {webbee-0.3.35 → webbee-0.3.36}/tests/test_config.py +0 -0
  82. {webbee-0.3.35 → webbee-0.3.36}/tests/test_cpc_contract_stable.py +0 -0
  83. {webbee-0.3.35 → webbee-0.3.36}/tests/test_details.py +0 -0
  84. {webbee-0.3.35 → webbee-0.3.36}/tests/test_events.py +0 -0
  85. {webbee-0.3.35 → webbee-0.3.36}/tests/test_freeze_fix.py +0 -0
  86. {webbee-0.3.35 → webbee-0.3.36}/tests/test_home.py +0 -0
  87. {webbee-0.3.35 → webbee-0.3.36}/tests/test_home_view.py +0 -0
  88. {webbee-0.3.35 → webbee-0.3.36}/tests/test_instance_lock.py +0 -0
  89. {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_chunker.py +0 -0
  90. {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_embed.py +0 -0
  91. {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_graph.py +0 -0
  92. {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_indexer.py +0 -0
  93. {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_no_numpy.py +0 -0
  94. {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_query.py +0 -0
  95. {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_service.py +0 -0
  96. {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_store.py +0 -0
  97. {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_vectors.py +0 -0
  98. {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_watch.py +0 -0
  99. {webbee-0.3.35 → webbee-0.3.36}/tests/test_marathon.py +0 -0
  100. {webbee-0.3.35 → webbee-0.3.36}/tests/test_mode_store.py +0 -0
  101. {webbee-0.3.35 → webbee-0.3.36}/tests/test_newtab_mode.py +0 -0
  102. {webbee-0.3.35 → webbee-0.3.36}/tests/test_packaging.py +0 -0
  103. {webbee-0.3.35 → webbee-0.3.36}/tests/test_reflow.py +0 -0
  104. {webbee-0.3.35 → webbee-0.3.36}/tests/test_render.py +0 -0
  105. {webbee-0.3.35 → webbee-0.3.36}/tests/test_repl.py +0 -0
  106. {webbee-0.3.35 → webbee-0.3.36}/tests/test_repo.py +0 -0
  107. {webbee-0.3.35 → webbee-0.3.36}/tests/test_session.py +0 -0
  108. {webbee-0.3.35 → webbee-0.3.36}/tests/test_sessions.py +0 -0
  109. {webbee-0.3.35 → webbee-0.3.36}/tests/test_sizing.py +0 -0
  110. {webbee-0.3.35 → webbee-0.3.36}/tests/test_slots.py +0 -0
  111. {webbee-0.3.35 → webbee-0.3.36}/tests/test_steer.py +0 -0
  112. {webbee-0.3.35 → webbee-0.3.36}/tests/test_stream.py +0 -0
  113. {webbee-0.3.35 → webbee-0.3.36}/tests/test_tabs.py +0 -0
  114. {webbee-0.3.35 → webbee-0.3.36}/tests/test_thread.py +0 -0
  115. {webbee-0.3.35 → webbee-0.3.36}/tests/test_todo_panel.py +0 -0
  116. {webbee-0.3.35 → webbee-0.3.36}/tests/test_tokens.py +0 -0
  117. {webbee-0.3.35 → webbee-0.3.36}/tests/test_tools.py +0 -0
  118. {webbee-0.3.35 → webbee-0.3.36}/tests/test_tui.py +0 -0
  119. {webbee-0.3.35 → webbee-0.3.36}/tests/test_tui_hardening.py +0 -0
  120. {webbee-0.3.35 → webbee-0.3.36}/tests/test_update.py +0 -0
  121. {webbee-0.3.35 → webbee-0.3.36}/tests/test_urlopen.py +0 -0
  122. {webbee-0.3.35 → webbee-0.3.36}/tests/test_version.py +0 -0
  123. {webbee-0.3.35 → webbee-0.3.36}/tests/test_wallet.py +0 -0
  124. {webbee-0.3.35 → webbee-0.3.36}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,32 @@
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
+
3
30
  ## 0.3.35
4
31
 
5
32
  Reliability and speed of the terminal itself.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.35
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.35"
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,
@@ -126,38 +126,100 @@ def _gate_busy(sink, turn_ref: dict) -> bool:
126
126
  return bool(cp and cp())
127
127
 
128
128
 
129
- def _slot_ctx(slot: SessionSlot, *, logged_in: bool) -> CommandContext:
129
+ def _slot_ctx(slot: SessionSlot, *, logged_in: bool, slots=None) -> CommandContext:
130
130
  """Pure extraction of the ACTIVE slot's fields into a CommandContext
131
131
  (W4a boot split, map §6): `state["mode"]`/`state["git_branch"]` are gone
132
132
  -- mode/git_branch/the type-ahead queue now live on the slot. Module-level
133
133
  so a test can drive slot-switching directly (build a SlotManager, flip
134
- active_idx, assert the fields follow) without running the whole REPL."""
134
+ active_idx, assert the fields follow) without running the whole REPL.
135
+
136
+ 0.3.36: `slots=` is optional and only matters for a SINK-LESS slot (Home).
137
+ Home has no counters of its own, so `/cost` and `/status` used to report a
138
+ flat 0 there; given the SlotManager they instead report the SESSION TOTAL
139
+ across every open tab -- the same pair the Home toolbar/dashboard show, so
140
+ the three can never disagree. Omitted (every existing caller/test) -> the
141
+ previous per-slot behaviour, byte for byte."""
135
142
  sink = slot.sink
143
+ tokens = getattr(sink, "session_tokens", 0)
144
+ credits = getattr(sink, "session_credits", 0)
145
+ if sink is None and slots is not None:
146
+ try:
147
+ from webbee.home_view import session_totals
148
+ tokens, credits = session_totals(slots)
149
+ except Exception:
150
+ tokens, credits = 0, 0
136
151
  return CommandContext(mode=slot.mode, workspace=slot.workspace, version=__version__,
137
152
  surface="terminal", logged_in=logged_in,
138
- session_tokens=getattr(sink, "session_tokens", 0),
139
- session_credits=getattr(sink, "session_credits", 0),
153
+ session_tokens=tokens,
154
+ session_credits=credits,
140
155
  git_branch=slot.git_branch, queued=tuple(slot.pending))
141
156
 
142
157
 
143
158
  # FIX4 (W4a final review — Home None-sink command crashes): actions that are
144
159
  # genuinely SESSION-scoped (an agent's steps, a workspace's checkpoints, this
145
- # session's spend/queue, account operations kept off the dashboard for
146
- # consistency) — dispatched while Home is active, they reply with ONE
160
+ # session's queue/mode) dispatched while Home is active, they reply with ONE
147
161
  # consistent "open a tab" note instead of either crashing on `_sink.note`
148
162
  # (sink is None) or quietly doing session-shaped work against Home's own
149
163
  # placeholder fields. `/clear`/`/tabs`/`/tab N`/`/new`/`/close`/`/help`/
150
164
  # `/exit` are deliberately NOT in this set — those are tab-bar/global actions
151
165
  # and must keep working on Home (map §FIX4).
166
+ #
167
+ # 0.3.36 — ACCOUNT-scoped commands were un-gated: `/login`, `/logout`,
168
+ # `/sessions`, `/sessions revoke`, `/logout-others` and `/cost` act on the
169
+ # ACCOUNT or DEVICE, not on any one session, so requiring a session tab first
170
+ # was an artificial block (reported live: "/login only works in a new tab").
171
+ # They are safe on Home because `_say` already tolerates a None sink and the
172
+ # two handlers that used `_sink` directly now go through `_say`/`_sink_or_say`.
173
+ # `/cost` on Home reports the SESSION TOTAL across every open tab (the same
174
+ # number the Home toolbar and dashboard show) instead of Home's own zeros.
152
175
  _HOME_GATED_ACTIONS = frozenset({
153
176
  "steps", "step_detail", "checkpoints", "rollback", "notify", "mode",
154
- "cost", "queue", "queue_clear", "login", "logout", "sessions",
155
- "sessions_revoke", "logout_others", "rename",
177
+ "queue", "queue_clear", "rename",
156
178
  })
157
179
 
158
180
  _HOME_GATE_NOTE = "open a session tab first — Ctrl+T or type a task"
159
181
 
160
182
 
183
+ class _SaySink:
184
+ """Minimal sink-shaped adapter over `_say` for the Home slot (0.3.36).
185
+
186
+ A few account-scoped handlers hand "the sink" to shared code that only
187
+ ever calls `.note(...)` on it -- `login_device_flow`'s fallback prompt is
188
+ the live example. On Home `slot.sink is None`, so passing it straight
189
+ through raised AttributeError (which is exactly why /login used to be
190
+ gated to session tabs). This shim routes those notes into Home's own
191
+ pane via `_say`, so the device code and URL are actually readable.
192
+ """
193
+
194
+ __slots__ = ("_slot",)
195
+
196
+ def __init__(self, slot: SessionSlot) -> None:
197
+ self._slot = slot
198
+
199
+ def note(self, msg: str) -> None:
200
+ _say(self._slot, str(msg))
201
+
202
+ def login_prompt(self, user_code: str, uri: str) -> None:
203
+ _say(self._slot, f"Open {uri} and enter code: {user_code}")
204
+
205
+
206
+ def _format_sessions_plain(rows) -> str:
207
+ """Active sessions as plain text -- Home's stand-in for the sink's rich
208
+ table (0.3.36). Same fields, same order, no Rich table dependency; PII
209
+ stays as the server sent it (the sink's table shows the same columns)."""
210
+ rows = list(rows or [])
211
+ if not rows:
212
+ return "Active sessions: (none)"
213
+ out = ["Active sessions:"]
214
+ for i, s in enumerate(rows, 1):
215
+ label = str(s.get("label") or s.get("surface") or "?")
216
+ seen = str(s.get("last_seen_at") or "")[:16].replace("T", " ")
217
+ here = " ← this device" if s.get("current") else ""
218
+ out.append(f" {i}. {label}" + (f" · {seen}" if seen else "") + here)
219
+ out.append("Revoke one with /sessions revoke <#>")
220
+ return "\n".join(out)
221
+
222
+
161
223
  def _say(slot: SessionSlot, msg: str) -> None:
162
224
  """Reply into `slot`'s own surface regardless of whether it has a live
163
225
  sink -- a REAL session slot's `sink.note(msg)`, unchanged; a sink-less
@@ -833,7 +895,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
833
895
  'continue'."""
834
896
  if not line.strip():
835
897
  return "continue"
836
- res = dispatch(line, _slot_ctx(slot, logged_in=state["logged_in"]))
898
+ res = dispatch(line, _slot_ctx(slot, logged_in=state["logged_in"], slots=slots))
837
899
  if res.handled:
838
900
  _sink = slot.sink
839
901
  if res.exit:
@@ -850,7 +912,11 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
850
912
  return "continue"
851
913
  if res.action == "login":
852
914
  # Device-code flow (RFC 8628) — rendering + polling in webbee.account.
853
- email = await login_device_flow(cfg, auth, _sink)
915
+ # 0.3.36: on Home `_sink` is None and login_device_flow's
916
+ # prompt path calls `.note(...)` -- `_SaySink` puts the device
917
+ # code + URL into Home's own pane instead of crashing.
918
+ email = await login_device_flow(
919
+ cfg, auth, _sink if _sink is not None else _SaySink(slot))
854
920
  state["logged_in"] = True
855
921
  _say(slot, f"Signed in as {email}.")
856
922
  return "continue"
@@ -862,7 +928,10 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
862
928
  if res.action == "sessions":
863
929
  rows = await sessions_client.list_sessions(cfg, token_provider)
864
930
  state["sessions"] = rows
865
- _sink.sessions_table(rows)
931
+ if _sink is not None:
932
+ _sink.sessions_table(rows)
933
+ else:
934
+ _say(slot, _format_sessions_plain(rows)) # Home: no rich table
866
935
  return "continue"
867
936
  if res.action == "sessions_revoke":
868
937
  rows = state.get("sessions") or []
@@ -1347,6 +1416,14 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
1347
1416
  home_view.data.notice = f"security & privacy: {url}"
1348
1417
  home_view.notify()
1349
1418
 
1419
+ def _home_sign_in() -> None:
1420
+ # 0.3.36: /login is no longer gated on Home, so the
1421
+ # dashboard's "Sign in" item just runs the SAME command
1422
+ # path (device-code flow, output via `_SaySink` into
1423
+ # Home's own pane) as typing /login here would.
1424
+ get_app().create_background_task(
1425
+ _handle("/login", slots.slots[0] if slots.slots else slots.active()))
1426
+
1350
1427
  # Home's command/output region — built FIRST via tui.OutputPane
1351
1428
  # so it is the dock's first OutputPane (the spy's created[0] =
1352
1429
  # Home) and `_say`/slash output on Home stays visible.
@@ -1361,6 +1438,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
1361
1438
  set_new_tab_mode=_set_new_tab_mode,
1362
1439
  top_up=_home_top_up,
1363
1440
  open_security_docs=_home_security_docs,
1441
+ sign_in=_home_sign_in,
1364
1442
  ))
1365
1443
  home_view.data.new_tab_mode = state["new_tab_mode"]
1366
1444
  # Home slot at index 0; the first session slot (cwd
@@ -24,6 +24,7 @@ from webbee import sizing
24
24
  from webbee.output_pane import OutputPane # noqa: F401 — re-exported (webbee.tui.OutputPane)
25
25
  from webbee.queue_panel import pull_item, queue_fragments, queue_height
26
26
  from webbee.render import _fmt_tokens
27
+ from webbee.home_view import session_totals
27
28
  from webbee.slots import close_active, close_at, disarm_all, is_turn_alive
28
29
  from webbee.tabs import tab_fragments
29
30
  from webbee.todo_panel import todo_fragments, todo_height
@@ -90,6 +91,10 @@ _STYLE_DICT = {
90
91
  "home.disabled": "#5f5f5f",
91
92
  "home.focus": "bg:#e8a317 #1c1c1c bold",
92
93
  "home.hint": "#00afd7",
94
+ "home.update": "#e8a317 bold", # a newer release exists — bee-yellow, pops
95
+ "home.fresh": "#5faf5f", # verified up to date — calm green
96
+ "home.spend": "#ffffff bold", # session spend total — reads as a figure
97
+ "input.cont": "#5f5f5f", # multi-line prompt continuation gutter
93
98
  }
94
99
 
95
100
 
@@ -132,6 +137,48 @@ def configure_mouse_modes(output) -> None:
132
137
  output.disable_mouse_support = _disable
133
138
 
134
139
 
140
+ def enable_csi_u_newline() -> bool:
141
+ """Teach prompt_toolkit's vt100 parser the CSI-u encodings of Shift+Enter.
142
+
143
+ prompt_toolkit 3.0.52 has NO ShiftEnter key: in a legacy terminal
144
+ Shift+Enter is byte-identical to Enter (CR), so it CANNOT be bound.
145
+ Modern terminals (kitty/foot/WezTerm/Ghostty, xterm with modifyOtherKeys,
146
+ iTerm2 with CSI-u) instead emit `ESC [13;<mod>u`. Registering those
147
+ sequences as (Escape, ControlM) makes them arrive as the SAME two-key
148
+ sequence Alt+Enter already produces, so ONE key binding -- ("escape",
149
+ "enter") -- serves both chords and nothing else in the app changes.
150
+
151
+ Registration is additive and idempotent: existing sequences are never
152
+ overwritten, so no other key can regress. Returns True when the table
153
+ now contains the codes (best effort -- never raises, an old/patched
154
+ prompt_toolkit just means Alt+Enter and Ctrl+J remain the way in).
155
+ """
156
+ try:
157
+ from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
158
+ from prompt_toolkit.keys import Keys
159
+ except Exception:
160
+ return False
161
+ combo = (Keys.Escape, Keys.ControlM)
162
+ # 13 = Enter's unicode codepoint (CR) in the CSI-u scheme; modifier code
163
+ # 2 = Shift, 4 = Alt(Meta), 6 = Ctrl+Shift, 8 = Ctrl+Alt+Shift.
164
+ for mod in (2, 4, 6, 8):
165
+ seq = f"\x1b[13;{mod}u"
166
+ try:
167
+ ANSI_SEQUENCES.setdefault(seq, combo)
168
+ except Exception:
169
+ return False
170
+ try:
171
+ # The parser memoises prefix lookups built from ANSI_SEQUENCES; drop
172
+ # the cache so sequences registered after import are actually seen.
173
+ from prompt_toolkit.input import vt100_parser as _vp
174
+ for _obj in vars(_vp).values():
175
+ if hasattr(_obj, "cache_clear"):
176
+ _obj.cache_clear()
177
+ except Exception:
178
+ pass
179
+ return True
180
+
181
+
135
182
  def input_rows(text: str, cols: int, cap: int) -> int:
136
183
  """PURE row-wrap estimator behind `_input_height` (module-level so tests
137
184
  drive it directly with an injected size, mirroring repl._gate_busy).
@@ -158,7 +205,7 @@ def next_mode(mode: str) -> str:
158
205
  def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
159
206
  current: str = "", elapsed: float = 0.0, tools: int = 0,
160
207
  consent: bool = False, queued: int = 0,
161
- reconnecting: int = 0) -> list:
208
+ reconnecting: int = 0, width: int = 0) -> list:
162
209
  """The status line under the pinned input box, as prompt_toolkit formatted
163
210
  text (per-segment styled). Four states: consent (awaiting a reply),
164
211
  reconnecting (the stream transport is down mid-turn — honest, not a fake
@@ -189,11 +236,23 @@ def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
189
236
  frags += q
190
237
  frags.append(("class:tb.dim", " · Esc/Ctrl-C to stop"))
191
238
  return frags
192
- return [("class:tb.dim", " mode: "),
193
- (f"class:tb.mode.{mode}", mode),
194
- ("class:tb.dim", f" · {_fmt_tokens(tokens)} tok · {_fmt_tokens(credits)} credits"),
195
- *q,
196
- ("class:tb.dim", " · Shift + TAB: switch mode")]
239
+ frags = [("class:tb.dim", " mode: "),
240
+ (f"class:tb.mode.{mode}", mode),
241
+ ("class:tb.dim", f" · {_fmt_tokens(tokens)} tok · {_fmt_tokens(credits)} credits this session"),
242
+ *q]
243
+ # 0.3.36: the hints are DISCOVERABILITY, the spend figures are DATA -- so
244
+ # the hints are what gets dropped when the terminal is too narrow to hold
245
+ # everything, never the numbers (an 80-column window used to cut the line
246
+ # mid-word). `width=0` (unknown/headless) keeps the full line, which is
247
+ # also what every pre-0.3.36 caller and test sees.
248
+ used = sum(len(t) for _, t in frags)
249
+ full = " · Alt+↵ newline · Shift + TAB: switch mode"
250
+ short = " · Alt+↵ newline"
251
+ if not width or used + len(full) <= width:
252
+ frags.append(("class:tb.dim", full))
253
+ elif used + len(short) <= width:
254
+ frags.append(("class:tb.dim", short))
255
+ return frags
197
256
 
198
257
 
199
258
  def _width_watch(pane, app) -> None:
@@ -764,6 +823,11 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
764
823
  finally:
765
824
  _finish_natural_turn(slot, done)
766
825
 
826
+ # Shift+Enter (CSI-u) must be known to the parser BEFORE any key is read,
827
+ # so the ("escape","enter") binding below can match it. Best effort: a
828
+ # False return just means Alt+Enter / Ctrl+J remain the newline chords.
829
+ enable_csi_u_newline()
830
+
767
831
  kb = KeyBindings()
768
832
 
769
833
  def _busy_live() -> bool:
@@ -855,6 +919,27 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
855
919
  ) in ("queued", "injected"):
856
920
  event.app.invalidate() # panel + toolbar show the new depth
857
921
 
922
+ # ---- newline INSIDE the prompt (0.3.36) -------------------------------
923
+ # Enter still SENDS (muscle memory is sacred); these insert a literal
924
+ # newline so one prompt can be many lines. THREE chords, because no
925
+ # single one is portable across every terminal/OS/SSH client:
926
+ # * Alt+Enter -> ("escape", "enter"). Universal: every terminal sends
927
+ # ESC as the Alt prefix. Same two-key shape as the Alt+N tab
928
+ # switches already bound above.
929
+ # * Shift+Enter -> ALSO ("escape", "enter"), because enable_csi_u_newline()
930
+ # registers the CSI-u codes modern terminals emit for it. This is the
931
+ # chord most people try first, so it works wherever the terminal can
932
+ # express it -- and degrades to Alt+Enter/Ctrl+J where it cannot.
933
+ # * Ctrl+J -> a raw LF (0x0a) that EVERY terminal, tmux/screen and
934
+ # ssh client can send, on every OS: the guaranteed fallback.
935
+ # Buffer(multiline=False) stores embedded "\n" fine and input_rows()
936
+ # already counts them, so the input box grows by itself.
937
+ def _insert_newline(event):
938
+ event.current_buffer.insert_text("\n")
939
+
940
+ kb.add("escape", "enter")(_insert_newline) # Alt+Enter (+ Shift+Enter via CSI-u)
941
+ kb.add("c-j")(_insert_newline) # raw LF -- universal fallback
942
+
858
943
  @kb.add("s-tab")
859
944
  def _cycle(event):
860
945
  slot = _a()
@@ -1039,11 +1124,20 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
1039
1124
  current=st["current"], elapsed=st["elapsed"],
1040
1125
  tools=st["tools"], consent=st["consent"],
1041
1126
  queued=len(slot.pending) + len(_sink_attr("remote_pending", [])),
1042
- reconnecting=st.get("reconnecting", 0))
1127
+ reconnecting=st.get("reconnecting", 0),
1128
+ width=sizing.get_size(get_app_or_none())[0])
1043
1129
  else:
1044
- # Home has no sink -- an idle toolbar of its own, no tokens/
1045
- # credits/mode to show (there's no session yet).
1046
- frags = [("class:tb.dim", " ◆ home · type to start a session · Alt+№ switch")]
1130
+ # Home has no sink of its OWN, but it is the dock's summary
1131
+ # page: show the SESSION TOTAL across every open tab (0.3.36)
1132
+ # so spend is visible from every tab, Home included -- the
1133
+ # per-tab numbers live in each sink, Home adds them up.
1134
+ tk, cr = session_totals(slots)
1135
+ frags = [("class:tb.dim", " ◆ home"),
1136
+ ("class:tb.dim", f" · {_fmt_tokens(tk)} tok · {_fmt_tokens(cr)} credits this session")]
1137
+ _w = sizing.get_size(get_app_or_none())[0]
1138
+ _hint = " · type a task to start · Alt+№ switch"
1139
+ if not _w or sum(len(t) for _, t in frags) + len(_hint) <= _w:
1140
+ frags.append(("class:tb.dim", _hint))
1047
1141
  # W2 Task 8: the toolbar has no mouse handling of its own, so
1048
1142
  # `_forwarding(None, pane)` is wrapped onto every fragment purely for
1049
1143
  # drag-forwarding — a release that lands on the toolbar row while a
@@ -1158,9 +1252,19 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
1158
1252
  always_hide_cursor=True, wrap_lines=False),
1159
1253
  filter=Condition(lambda: bool(_sink_attr("current_todos", []))))
1160
1254
 
1255
+ def _line_prefix(line_no, wrap_count):
1256
+ # 0.3.36: with multi-line prompts, line 2+ gets a dim continuation
1257
+ # gutter so it reads as ONE message still being composed, never as
1258
+ # something already sent. Line 1 keeps the coloured `❯ ` that
1259
+ # BeforeInput draws (returning "" leaves it untouched). Aligned to
1260
+ # the same 2 columns as `❯ `, so text never shifts as it wraps.
1261
+ if line_no == 0 and not wrap_count:
1262
+ return ""
1263
+ return [("class:input.cont", "\u250a ")]
1264
+
1161
1265
  input_win = Window(
1162
1266
  BufferControl(buffer=buf, input_processors=[BeforeInput(_prompt_fragments)]),
1163
- height=_input_height, wrap_lines=True)
1267
+ height=_input_height, wrap_lines=True, get_line_prefix=_line_prefix)
1164
1268
  toolbar = Window(FormattedTextControl(_toolbar), height=1, always_hide_cursor=True)
1165
1269
 
1166
1270
  _hover_on = {"v": None}