webbee 0.3.43__tar.gz → 0.3.45__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 (129) hide show
  1. {webbee-0.3.43 → webbee-0.3.45}/CHANGELOG.md +42 -0
  2. {webbee-0.3.43 → webbee-0.3.45}/PKG-INFO +1 -1
  3. {webbee-0.3.43 → webbee-0.3.45}/pyproject.toml +1 -1
  4. webbee-0.3.45/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/home.py +49 -1
  6. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/render.py +51 -7
  7. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/repl.py +18 -2
  8. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/tui.py +13 -2
  9. {webbee-0.3.43 → webbee-0.3.45}/tests/test_home.py +49 -2
  10. {webbee-0.3.43 → webbee-0.3.45}/tests/test_render.py +43 -0
  11. {webbee-0.3.43 → webbee-0.3.45}/tests/test_tui.py +37 -0
  12. webbee-0.3.43/src/webbee/__init__.py +0 -1
  13. {webbee-0.3.43 → webbee-0.3.45}/.github/workflows/publish.yml +0 -0
  14. {webbee-0.3.43 → webbee-0.3.45}/.gitignore +0 -0
  15. {webbee-0.3.43 → webbee-0.3.45}/LICENSE +0 -0
  16. {webbee-0.3.43 → webbee-0.3.45}/README.md +0 -0
  17. {webbee-0.3.43 → webbee-0.3.45}/install.sh +0 -0
  18. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/account.py +0 -0
  19. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/active_sessions.py +0 -0
  20. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/banner_art.py +0 -0
  21. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/boot.py +0 -0
  22. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/checkpoints.py +0 -0
  23. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/cli.py +0 -0
  24. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/clipboard.py +0 -0
  25. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/clipboard_read.py +0 -0
  26. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/coding_context.py +0 -0
  27. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/commands.py +0 -0
  28. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/config.py +0 -0
  29. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/consent.py +0 -0
  30. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/details.py +0 -0
  31. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/events.py +0 -0
  32. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/frames.py +0 -0
  33. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/home_view.py +0 -0
  34. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/http.py +0 -0
  35. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/instance_lock.py +0 -0
  36. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/intel/__init__.py +0 -0
  37. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/intel/chunker.py +0 -0
  38. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/intel/embed.py +0 -0
  39. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/intel/graph.py +0 -0
  40. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/intel/indexer.py +0 -0
  41. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/intel/models.py +0 -0
  42. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/intel/query.py +0 -0
  43. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/intel/service.py +0 -0
  44. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/intel/store.py +0 -0
  45. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/intel/vectors.py +0 -0
  46. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/intel/watch.py +0 -0
  47. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/mode_store.py +0 -0
  48. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/newtab_mode.py +0 -0
  49. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/output_pane.py +0 -0
  50. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/queue_panel.py +0 -0
  51. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/reflow.py +0 -0
  52. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/remote.py +0 -0
  53. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/repo.py +0 -0
  54. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/selection.py +0 -0
  55. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/session.py +0 -0
  56. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/sessions.py +0 -0
  57. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/sizing.py +0 -0
  58. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/slots.py +0 -0
  59. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/steer.py +0 -0
  60. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/stream.py +0 -0
  61. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/tab_store.py +0 -0
  62. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/tabs.py +0 -0
  63. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/thread.py +0 -0
  64. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/tier_store.py +0 -0
  65. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/todo_panel.py +0 -0
  66. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/tokens.py +0 -0
  67. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/tools.py +0 -0
  68. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/update.py +0 -0
  69. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/urlopen.py +0 -0
  70. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/wallet.py +0 -0
  71. {webbee-0.3.43 → webbee-0.3.45}/src/webbee/worktrees.py +0 -0
  72. {webbee-0.3.43 → webbee-0.3.45}/tests/__init__.py +0 -0
  73. {webbee-0.3.43 → webbee-0.3.45}/tests/conftest.py +0 -0
  74. {webbee-0.3.43 → webbee-0.3.45}/tests/test_account.py +0 -0
  75. {webbee-0.3.43 → webbee-0.3.45}/tests/test_active_sessions.py +0 -0
  76. {webbee-0.3.43 → webbee-0.3.45}/tests/test_audit_2026_07_25_findings.py +0 -0
  77. {webbee-0.3.43 → webbee-0.3.45}/tests/test_checkpoints.py +0 -0
  78. {webbee-0.3.43 → webbee-0.3.45}/tests/test_cli.py +0 -0
  79. {webbee-0.3.43 → webbee-0.3.45}/tests/test_clipboard.py +0 -0
  80. {webbee-0.3.43 → webbee-0.3.45}/tests/test_clipboard_read.py +0 -0
  81. {webbee-0.3.43 → webbee-0.3.45}/tests/test_coding_context.py +0 -0
  82. {webbee-0.3.43 → webbee-0.3.45}/tests/test_commands.py +0 -0
  83. {webbee-0.3.43 → webbee-0.3.45}/tests/test_config.py +0 -0
  84. {webbee-0.3.43 → webbee-0.3.45}/tests/test_cpc_contract_stable.py +0 -0
  85. {webbee-0.3.43 → webbee-0.3.45}/tests/test_details.py +0 -0
  86. {webbee-0.3.43 → webbee-0.3.45}/tests/test_events.py +0 -0
  87. {webbee-0.3.43 → webbee-0.3.45}/tests/test_freeze_fix.py +0 -0
  88. {webbee-0.3.43 → webbee-0.3.45}/tests/test_home_view.py +0 -0
  89. {webbee-0.3.43 → webbee-0.3.45}/tests/test_instance_lock.py +0 -0
  90. {webbee-0.3.43 → webbee-0.3.45}/tests/test_intel_chunker.py +0 -0
  91. {webbee-0.3.43 → webbee-0.3.45}/tests/test_intel_embed.py +0 -0
  92. {webbee-0.3.43 → webbee-0.3.45}/tests/test_intel_graph.py +0 -0
  93. {webbee-0.3.43 → webbee-0.3.45}/tests/test_intel_indexer.py +0 -0
  94. {webbee-0.3.43 → webbee-0.3.45}/tests/test_intel_no_numpy.py +0 -0
  95. {webbee-0.3.43 → webbee-0.3.45}/tests/test_intel_query.py +0 -0
  96. {webbee-0.3.43 → webbee-0.3.45}/tests/test_intel_service.py +0 -0
  97. {webbee-0.3.43 → webbee-0.3.45}/tests/test_intel_store.py +0 -0
  98. {webbee-0.3.43 → webbee-0.3.45}/tests/test_intel_vectors.py +0 -0
  99. {webbee-0.3.43 → webbee-0.3.45}/tests/test_intel_watch.py +0 -0
  100. {webbee-0.3.43 → webbee-0.3.45}/tests/test_marathon.py +0 -0
  101. {webbee-0.3.43 → webbee-0.3.45}/tests/test_mode_store.py +0 -0
  102. {webbee-0.3.43 → webbee-0.3.45}/tests/test_newtab_mode.py +0 -0
  103. {webbee-0.3.43 → webbee-0.3.45}/tests/test_packaging.py +0 -0
  104. {webbee-0.3.43 → webbee-0.3.45}/tests/test_queue_manage_0337.py +0 -0
  105. {webbee-0.3.43 → webbee-0.3.45}/tests/test_reflow.py +0 -0
  106. {webbee-0.3.43 → webbee-0.3.45}/tests/test_repl.py +0 -0
  107. {webbee-0.3.43 → webbee-0.3.45}/tests/test_repo.py +0 -0
  108. {webbee-0.3.43 → webbee-0.3.45}/tests/test_session.py +0 -0
  109. {webbee-0.3.43 → webbee-0.3.45}/tests/test_sessions.py +0 -0
  110. {webbee-0.3.43 → webbee-0.3.45}/tests/test_sizing.py +0 -0
  111. {webbee-0.3.43 → webbee-0.3.45}/tests/test_slots.py +0 -0
  112. {webbee-0.3.43 → webbee-0.3.45}/tests/test_steer.py +0 -0
  113. {webbee-0.3.43 → webbee-0.3.45}/tests/test_stream.py +0 -0
  114. {webbee-0.3.43 → webbee-0.3.45}/tests/test_tab_restore_0337.py +0 -0
  115. {webbee-0.3.43 → webbee-0.3.45}/tests/test_tabs.py +0 -0
  116. {webbee-0.3.43 → webbee-0.3.45}/tests/test_terminal_ux_0336.py +0 -0
  117. {webbee-0.3.43 → webbee-0.3.45}/tests/test_thread.py +0 -0
  118. {webbee-0.3.43 → webbee-0.3.45}/tests/test_tier_store.py +0 -0
  119. {webbee-0.3.43 → webbee-0.3.45}/tests/test_todo_panel.py +0 -0
  120. {webbee-0.3.43 → webbee-0.3.45}/tests/test_tokens.py +0 -0
  121. {webbee-0.3.43 → webbee-0.3.45}/tests/test_tools.py +0 -0
  122. {webbee-0.3.43 → webbee-0.3.45}/tests/test_tui_hardening.py +0 -0
  123. {webbee-0.3.43 → webbee-0.3.45}/tests/test_update.py +0 -0
  124. {webbee-0.3.43 → webbee-0.3.45}/tests/test_update_badge_0340.py +0 -0
  125. {webbee-0.3.43 → webbee-0.3.45}/tests/test_urlopen.py +0 -0
  126. {webbee-0.3.43 → webbee-0.3.45}/tests/test_version.py +0 -0
  127. {webbee-0.3.43 → webbee-0.3.45}/tests/test_version_badge_corner.py +0 -0
  128. {webbee-0.3.43 → webbee-0.3.45}/tests/test_wallet.py +0 -0
  129. {webbee-0.3.43 → webbee-0.3.45}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.45
4
+
5
+ Consent prompts stop being an easy-to-miss detail:
6
+
7
+ - **Both the kernel consent (`? approve ...`) and the local remote-control
8
+ yes/no ask now print as a bordered card, right in the conversation, the
9
+ moment they're asked** -- the same proven Panel this terminal already
10
+ uses for the billing and login prompts, so it reflows correctly to
11
+ whatever width the terminal actually has (a phone's SSH client, a narrow
12
+ tmux pane, a full window). The question and the "how to answer"
13
+ instructions (`y/yes -> do it`, `n/no -> skip it`, or type your own
14
+ reply) now live in the SAME box -- nothing to go hunting for in a
15
+ toolbar hint several rows away. The underlying protocol is unchanged:
16
+ still a bare raw string relayed verbatim for the kernel to interpret
17
+ (ICNLI, safe-by-default) -- this is a display-only change.
18
+
19
+ ## 0.3.44
20
+
21
+ Three real Home-dashboard bugs, found and fixed:
22
+
23
+ - **Each open tab's elapsed time now actually ticks live, per tab.** The
24
+ numbers themselves were always computed correctly per-tab, but Home only
25
+ ever redrew while a turn was busy or a toast was fresh -- sitting idle on
26
+ Home (the normal way to look at your tabs) froze every age at whatever it
27
+ was the moment you switched there, which looked exactly like "one shared
28
+ time for all tabs" even though it wasn't. Home now redraws on the plain
29
+ 1s idle tick too, so every tab's own age genuinely counts up in place.
30
+ - **"Your devices" no longer lists dead terminal sessions.** A crashed
31
+ terminal or a laptop that lost network never calls the gateway's revoke
32
+ endpoint on its way out, so the raw session listing accumulated stale
33
+ entries forever. The list now hides a row only once its own last-activity
34
+ timestamp proves it's actually gone (>24h) -- your current device always
35
+ shows, and a row with no timestamp at all (older data) still shows rather
36
+ than risk hiding something real.
37
+ - **The notification toggle now reflects what the server actually
38
+ confirmed, not just what you clicked.** The old code echoed the click's
39
+ raw argument straight into the display the instant the request was sent,
40
+ so a write the server silently rejected or normalized still looked like
41
+ it had taken effect. It now reads back the server's own state after the
42
+ write, and clicking with no session tab open tells you plainly instead of
43
+ doing nothing silently.
44
+
3
45
  ## 0.3.43
4
46
 
5
47
  Honest fix-up of three things 0.3.42 got wrong or left unfinished:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.43
3
+ Version: 0.3.45
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.43"
3
+ version = "0.3.45"
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.45"
@@ -100,6 +100,49 @@ def _device_label(s: dict) -> str:
100
100
  return "session"
101
101
 
102
102
 
103
+ DEVICE_STALE_TTL_S = 24 * 3600 # webbee-code-home-live-devices-v1
104
+
105
+
106
+ def _is_recently_active(s: dict, now: float, ttl: float = DEVICE_STALE_TTL_S) -> bool:
107
+ """PURE. Whether a `/v1/auth/sessions` row is worth showing on Home's
108
+ device list. `/v1/auth/sessions` lists every session token the gateway
109
+ has ever ISSUED that hasn't been explicitly revoked -- a terminal that
110
+ crashed or a laptop that lost network never calls revoke on its way out,
111
+ so the raw listing accumulates dead entries forever (Valentin, live
112
+ 2026-07-31: "a bunch of dead terminal sessions"). The CURRENT session
113
+ (`current`/`is_current`) always counts as active regardless of its own
114
+ timestamp -- it's the one you're looking at right now. Every other row
115
+ is checked against `last_seen_at` (or `updated_at`/`created_at` as a
116
+ fallback) when the gateway actually SENDS one of those fields: only a
117
+ successfully-parsed timestamp older than `ttl` hides the row. A row with
118
+ NONE of those fields at all (an older gateway shape that never sends
119
+ them) degrades the OTHER way -- unknown is shown, never hidden, since
120
+ hiding a device we simply have no data on risks hiding a real one; only
121
+ a row we can actually PROVE is stale gets filtered out."""
122
+ if bool(s.get("current") or s.get("is_current")):
123
+ return True
124
+ for k in ("last_seen_at", "updated_at", "created_at"):
125
+ iso = s.get(k)
126
+ if not iso:
127
+ continue
128
+ try:
129
+ from datetime import datetime, timezone
130
+ ss = str(iso).replace("Z", "+00:00")
131
+ dt = datetime.fromisoformat(ss)
132
+ if dt.tzinfo is None:
133
+ dt = dt.replace(tzinfo=timezone.utc)
134
+ age = (datetime.now(timezone.utc) - dt).total_seconds()
135
+ if age < 0:
136
+ continue # malformed (future) timestamp -- try the next field
137
+ return age <= ttl
138
+ except Exception:
139
+ continue
140
+ # every timestamp field was absent, unparseable, or in the future:
141
+ # unknown, not PROVEN stale -- show it (see docstring) rather than
142
+ # silently hide a real device we simply have no good data on.
143
+ return True
144
+
145
+
103
146
  async def fill_home(slot, *, cfg, token_provider, slots, account_fetcher,
104
147
  sessions_client, resources, version, wallet_fetcher=None) -> None:
105
148
  """Populate the Home dashboard's HomeData, best-effort, staged (a repaint
@@ -161,9 +204,14 @@ async def fill_home(slot, *, cfg, token_provider, slots, account_fetcher,
161
204
 
162
205
  try:
163
206
  listing = await sessions_client.list_sessions(cfg, token_provider)
207
+ # webbee-code-home-live-devices-v1: drop rows the gateway never
208
+ # got a chance to revoke (crashed terminal, dead laptop) so the
209
+ # list reads as "your real devices", not a graveyard of old
210
+ # terminal sessions -- see _is_recently_active's own docstring.
211
+ now = time.time()
164
212
  data.devices = [DeviceRow(label=_device_label(s),
165
213
  current=bool(s.get("current") or s.get("is_current")))
166
- for s in (listing or [])]
214
+ for s in (listing or []) if _is_recently_active(s, now)]
167
215
  except Exception:
168
216
  data.devices = []
169
217
  _repaint()
@@ -614,12 +614,38 @@ class RichSink:
614
614
  NEVER interpret (the kernel decides, ICNLI). When the dock is running
615
615
  the reply comes through the pinned box via an asyncio.Future (no
616
616
  blocking input on the event loop); otherwise fall back to the injected
617
- sync reader (tests / non-tty)."""
617
+ sync reader (tests / non-tty).
618
+
619
+ webbee-code-consent-card-v1: this used to be one dim, easy-to-miss
620
+ line in the feed plus a SEPARATE hint buried in the toolbar below --
621
+ several users said it wasn't obvious what was even being asked, let
622
+ alone how to answer it. Now it's a bordered card printed RIGHT IN
623
+ THE CONVERSATION, at the moment it's asked -- same proven Panel this
624
+ module already uses for the billing/login prompts, so it reflows to
625
+ whatever width the terminal actually has (phone SSH client, a narrow
626
+ tmux pane, a full-width window -- all get a readable card, not a
627
+ clipped line). The question AND the "how to answer" instructions
628
+ live in the SAME box, so there's nothing to hunt for elsewhere on
629
+ screen. The protocol contract is unchanged: still a bare raw string
630
+ relayed verbatim for the kernel to interpret (ICNLI, safe-by-default)."""
618
631
  label = _clean(f"{app_id}·{tool}" if app_id else tool)
619
- sal = _clean(_salient_arg(args))
620
- w = self.console.width
621
- self.console.print(Text.assemble((" ? approve ", "yellow"), (label, "dim"),
622
- ((" " + sal[:trunc(w, 0.4, 60)]) if sal else "", "dim")))
632
+ sal = _clean(_salient_arg(args))[:500]
633
+ body = Text()
634
+ body.append(label, style=f"bold {_ACCENT}")
635
+ if sal:
636
+ body.append("\n" + sal, style="white")
637
+ body.append("\n\n")
638
+ body.append("y", style="bold green")
639
+ body.append(" / ", style="dim")
640
+ body.append("yes", style="bold green")
641
+ body.append(" → do it ", style="dim")
642
+ body.append("n", style="bold red")
643
+ body.append(" / ", style="dim")
644
+ body.append("no", style="bold red")
645
+ body.append(" → skip it\n", style="dim")
646
+ body.append("or just type your own reply in any language — Enter to send", style="dim")
647
+ self.console.print(_pad(Panel(body, title="🔐 wants to do this — approve?",
648
+ border_style=_BEE)))
623
649
  fut = self._arm_consent(label, sal)
624
650
  if fut is None: # non-tty / no running app
625
651
  raw = self._input(" ")
@@ -643,8 +669,26 @@ class RichSink:
643
669
  all return False, so the caller keeps the current mode. This is a
644
670
  LOCAL policy decision, not a kernel consent, so the reply is
645
671
  interpreted here rather than relayed (ICNLI raw-relay applies to
646
- kernel consents only)."""
647
- self.console.print(_pad(Text("⚠ " + _clean(question), style=f"bold {_BEE}")))
672
+ kernel consents only).
673
+
674
+ webbee-code-consent-card-v1: same bordered-card treatment as
675
+ ask_consent -- a bare "⚠ ..." line was just as easy to miss as the
676
+ old one-line kernel consent was. The card reflows to the terminal's
677
+ real width on any device, and states the y/n instructions right
678
+ where the question is instead of leaving them implicit."""
679
+ body = Text()
680
+ body.append(_clean(question), style=f"bold {_BEE}")
681
+ body.append("\n\n")
682
+ body.append("y", style="bold green")
683
+ body.append(" / ", style="dim")
684
+ body.append("yes", style="bold green")
685
+ body.append(" → allow ", style="dim")
686
+ body.append("n", style="bold red")
687
+ body.append(" / ", style="dim")
688
+ body.append("no", style="bold red")
689
+ body.append(" → stay as-is (default)", style="dim")
690
+ self.console.print(_pad(Panel(body, title="⚠ another surface is asking",
691
+ border_style=_BEE)))
648
692
  fut = self._arm_consent(question, question)
649
693
  try:
650
694
  if fut is None: # non-tty / no dock → sync reader
@@ -1727,17 +1727,33 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
1727
1727
  state["new_tab_mode"] = newtab_mode.load_newtab_mode() or mode
1728
1728
 
1729
1729
  def _home_set_notify(arg: str) -> None:
1730
+ # webbee-code-home-live-notify-v1: resolve the TARGET
1731
+ # session ONCE, up front, and thread that SAME sid through
1732
+ # to the write below -- the old code re-resolved
1733
+ # "whichever session Home currently points at" only inside
1734
+ # the click handler, which is fine, but never told the
1735
+ # user anything when there was no session tab to apply it
1736
+ # to (silent no-op looked exactly like "did nothing" --
1737
+ # Valentin, live 2026-07-31: "doesn't show real values").
1730
1738
  picked = _pick_session_slot(slots)
1731
1739
  sid = getattr(getattr(picked, "agent", None), "session_id", "") if picked else ""
1732
1740
  if not sid:
1741
+ home_view.data.notice = "open a session tab first — notifications are per-session"
1742
+ home_view.notify()
1733
1743
  return
1734
1744
  async def _do():
1735
1745
  try:
1736
1746
  st = await _remote_mod.set_remote(cfg, token_provider, sid, arg)
1737
- home_view.data.notify_state = arg
1747
+ # webbee-code-home-live-notify-v1: show what the
1748
+ # SERVER actually confirmed (from_notify_state_from
1749
+ # its own state), never the raw click argument --
1750
+ # the old code trusted `arg` unconditionally, so a
1751
+ # write the server silently rejected/normalized
1752
+ # still displayed as if it had taken effect.
1753
+ home_view.data.notify_state = home._notify_state_from(st)
1738
1754
  home_view.data.remote_desc = _remote_mod.describe(st)
1739
1755
  except Exception:
1740
- pass
1756
+ home_view.data.notice = "couldn't update notifications — try again"
1741
1757
  home_view.notify()
1742
1758
  get_app().create_background_task(_do())
1743
1759
 
@@ -477,7 +477,8 @@ def _tick_once(slots, app, is_busy, breathing=None) -> None:
477
477
  when a turn is running OR the copy-flash toast is still fresh, so the
478
478
  spinner/elapsed-clock/flash-expiry all animate without redrawing on every
479
479
  idle tick for nothing."""
480
- pane = slots.active().pane
480
+ active = slots.active()
481
+ pane = active.pane
481
482
  _width_watch(pane, app)
482
483
  try:
483
484
  pane.edge_tick()
@@ -488,7 +489,17 @@ def _tick_once(slots, app, is_busy, breathing=None) -> None:
488
489
  # state right now -- if so the idle 1.0s-cadence tick still has to
489
490
  # actually REDRAW so the alternating shade is ever seen, not just
490
491
  # computed and silently discarded on every tick the same as a no-op one.
491
- if is_busy() or pane.flash() or (breathing is not None and breathing()):
492
+ # webbee-code-home-live-ages-v1: Home's own tab-list ages/durations are
493
+ # computed FRESH every render (home_view.tab_rows reads time.monotonic()
494
+ # each call) -- but a render only happens on an app.invalidate(), and the
495
+ # busy/flash/breathing checks above are ALL false while you're simply
496
+ # sitting on Home doing nothing. That froze every age at whatever it was
497
+ # the moment you switched to Home -- looking like one shared, wrong
498
+ # number instead of each tab's own live-ticking age. Home is cheap to
499
+ # redraw (same virtualized-slice render every other pane already does),
500
+ # so it gets the plain 1s idle cadence's invalidate too, unconditionally.
501
+ if (is_busy() or pane.flash() or (breathing is not None and breathing())
502
+ or getattr(active, "kind", "") == "home"):
492
503
  app.invalidate()
493
504
 
494
505
 
@@ -4,8 +4,8 @@ tiles, async best-effort fill, is_stale/refill scheduling, and home_input's
4
4
  import asyncio
5
5
 
6
6
  from webbee.account import Account
7
- from webbee.home import (_mask_email, _notify_state_from, _pick_session_slot,
8
- fill_home, is_stale)
7
+ from webbee.home import (_is_recently_active, _mask_email, _notify_state_from,
8
+ _pick_session_slot, fill_home, is_stale)
9
9
  from webbee.repl import _home_input, _home_target_workspace, _schedule_home_refill
10
10
  from webbee.slots import SessionSlot, SlotManager, WorkspaceResources
11
11
  from webbee.wallet import Wallet
@@ -175,6 +175,53 @@ def test_fill_home_builds_device_rows_without_pii():
175
175
  assert not any("1.2.3.4" in lbl for lbl in labels) # raw IP never surfaced
176
176
 
177
177
 
178
+ def test_is_recently_active_keeps_current_regardless_of_timestamp():
179
+ # webbee-code-home-live-devices-v1: the row you're looking at right now
180
+ # always counts as active, even with a wildly stale/missing timestamp.
181
+ assert _is_recently_active({"current": True}, now=1_000_000.0) is True
182
+ assert _is_recently_active({"is_current": True, "last_seen_at": "2000-01-01T00:00:00Z"},
183
+ now=1_000_000.0) is True
184
+
185
+
186
+ def test_is_recently_active_hides_a_proven_stale_row():
187
+ from datetime import datetime, timedelta, timezone
188
+ old_ts = (datetime.now(timezone.utc) - timedelta(days=40)).isoformat()
189
+ assert _is_recently_active({"last_seen_at": old_ts}, now=0.0) is False
190
+
191
+
192
+ def test_is_recently_active_shows_a_recent_row():
193
+ from datetime import datetime, timezone
194
+ fresh_ts = datetime.now(timezone.utc).isoformat()
195
+ assert _is_recently_active({"last_seen_at": fresh_ts}, now=0.0) is True
196
+
197
+
198
+ def test_is_recently_active_shows_unknown_rows_never_proven_stale():
199
+ # No timestamp field at all (older gateway shape) -- unknown, not
200
+ # PROVEN stale, so it must still show rather than silently vanish.
201
+ assert _is_recently_active({"label": "old client"}, now=0.0) is True
202
+ # An unparseable timestamp degrades the same safe way.
203
+ assert _is_recently_active({"last_seen_at": "not-a-date"}, now=0.0) is True
204
+
205
+
206
+ def test_fill_home_filters_stale_devices():
207
+ from datetime import datetime, timedelta, timezone
208
+ async def acct(cfg, tp):
209
+ return Account(signed_in=False)
210
+ fresh_ts = datetime.now(timezone.utc).isoformat()
211
+ stale_ts = (datetime.now(timezone.utc) - timedelta(days=40)).isoformat()
212
+ listing = [{"label": "this laptop", "current": True},
213
+ {"label": "dead terminal", "last_seen_at": stale_ts},
214
+ {"label": "phone, recently used", "last_seen_at": fresh_ts}]
215
+ home = _home_slot()
216
+ slots = SlotManager(); slots.add(home)
217
+ asyncio.run(fill_home(home, cfg=_Cfg(), token_provider=_tok, slots=slots,
218
+ account_fetcher=acct, sessions_client=_FakeSessions(listing),
219
+ resources=WorkspaceResources(), version="1.0.0", wallet_fetcher=None))
220
+ labels = [r.label for r in home.pane.data.devices]
221
+ assert labels == ["this laptop", "phone, recently used"]
222
+ assert "dead terminal" not in labels
223
+
224
+
178
225
  def test_fill_home_reentrancy_guard():
179
226
  calls = []
180
227
  async def counting(cfg, tp):
@@ -299,6 +299,49 @@ def test_consent_shows_human_summary_not_dict():
299
299
  assert "notes·delete_note" in out
300
300
 
301
301
 
302
+ # ── webbee-code-consent-card-v1: the consent card is a real, hard-to-miss
303
+ # bordered block printed IN the conversation, with the y/n instructions
304
+ # living right next to the question -- not a dim one-liner + a separate
305
+ # toolbar hint the user has to go hunting for. ────────────────────────────
306
+
307
+ def test_consent_is_a_bordered_card_with_inline_instructions():
308
+ console = Console(record=True, width=90)
309
+ s = RichSink(console=console, live_enabled=False,
310
+ input_fn=lambda p: "yes", clock=lambda: 0.0)
311
+ asyncio.run(s.ask_consent("notes", "delete_note", {"title": "Q3 budget"}))
312
+ out = console.export_text()
313
+ # a real Panel border, not a bare printed line
314
+ assert "\u256d" in out or "\u2500" in out # rounded-corner or horizontal rule glyph
315
+ assert "approve?" in out
316
+ # the how-to-answer instructions are IN the card, not somewhere else
317
+ assert "yes" in out and "no" in out
318
+ assert "do it" in out and "skip it" in out
319
+ assert "notes·delete_note" in out
320
+
321
+
322
+ def test_consent_card_reflows_to_narrow_terminal_width():
323
+ # A phone SSH client / narrow tmux pane: the card must not crash or
324
+ # silently drop the question -- Panel reflows, same as billing/login.
325
+ console = Console(record=True, width=28)
326
+ s = RichSink(console=console, live_enabled=False,
327
+ input_fn=lambda p: "y", clock=lambda: 0.0)
328
+ asyncio.run(s.ask_consent("mail", "delete", {"query": "old newsletters"}))
329
+ out = console.export_text()
330
+ assert "mail" in out and "delete" in out
331
+ assert "yes" in out and "no" in out
332
+
333
+
334
+ def test_ask_yes_no_is_also_a_bordered_card_with_instructions():
335
+ console = Console(record=True, width=90)
336
+ s = RichSink(console=console, live_enabled=False,
337
+ input_fn=lambda p: "y", clock=lambda: 0.0)
338
+ asyncio.run(s.ask_yes_no("switch to autopilot — allow? [y/n]"))
339
+ out = console.export_text()
340
+ assert "\u256d" in out or "\u2500" in out
341
+ assert "allow" in out.lower()
342
+ assert "yes" in out and "no" in out
343
+
344
+
302
345
  def test_user_echo_is_unboxed_line():
303
346
  s = _sink()
304
347
  s.user_echo("delete my Q3 note")
@@ -1548,6 +1548,43 @@ def test_tick_once_fires_width_watch_edge_tick_and_invalidate(monkeypatch):
1548
1548
  assert calls["invalidate"] == 2 # is_busy() True -> app.invalidate()
1549
1549
 
1550
1550
 
1551
+ def test_tick_once_always_invalidates_on_home_even_when_idle(monkeypatch):
1552
+ # webbee-code-home-live-ages-v1: Home's tab list computes each tab's own
1553
+ # age fresh every render, but a render only happens on invalidate() --
1554
+ # so sitting idle on Home (not busy, no flash, not breathing) must NOT
1555
+ # skip the redraw the way every other idle pane correctly does, or every
1556
+ # tab's elapsed time freezes at whatever it was when you switched here.
1557
+ from webbee.tui import _tick_once
1558
+
1559
+ class _Pane:
1560
+ def __init__(self):
1561
+ self.console = type("Console", (), {"width": 72})()
1562
+ def reflow(self, cols):
1563
+ pass
1564
+ def edge_tick(self):
1565
+ pass
1566
+ def flash(self):
1567
+ return ""
1568
+
1569
+ class _App:
1570
+ def __init__(self):
1571
+ self.n = 0
1572
+ def invalidate(self):
1573
+ self.n += 1
1574
+
1575
+ monkeypatch.setattr("webbee.sizing.get_size", lambda app: (72, 24))
1576
+ slots = mk_slots(pane=_Pane())
1577
+ slots.slots[0].kind = "home"
1578
+ app = _App()
1579
+ _tick_once(slots, app, lambda: False) # idle, no breathing
1580
+ assert app.n == 1 # still invalidated because the active slot is Home
1581
+
1582
+ slots.slots[0].kind = "session"
1583
+ app2 = _App()
1584
+ _tick_once(slots, app2, lambda: False)
1585
+ assert app2.n == 0 # a genuinely idle SESSION tab still skips the redraw
1586
+
1587
+
1551
1588
  def test_tick_once_swallows_edge_tick_error(monkeypatch):
1552
1589
  from webbee.tui import _tick_once
1553
1590
 
@@ -1 +0,0 @@
1
- __version__ = "0.3.43"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes