webbee 0.3.17__tar.gz → 0.3.19__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 (104) hide show
  1. {webbee-0.3.17 → webbee-0.3.19}/CHANGELOG.md +33 -0
  2. {webbee-0.3.17 → webbee-0.3.19}/PKG-INFO +1 -1
  3. {webbee-0.3.17 → webbee-0.3.19}/pyproject.toml +1 -1
  4. webbee-0.3.19/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/boot.py +31 -3
  6. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/commands.py +16 -0
  7. webbee-0.3.19/src/webbee/home.py +290 -0
  8. webbee-0.3.19/src/webbee/output_pane.py +351 -0
  9. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/queue_panel.py +41 -18
  10. webbee-0.3.19/src/webbee/reflow.py +53 -0
  11. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/render.py +37 -24
  12. webbee-0.3.19/src/webbee/repl.py +1045 -0
  13. webbee-0.3.19/src/webbee/selection.py +160 -0
  14. webbee-0.3.19/src/webbee/sizing.py +40 -0
  15. webbee-0.3.19/src/webbee/slots.py +146 -0
  16. webbee-0.3.19/src/webbee/tabs.py +125 -0
  17. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/todo_panel.py +23 -14
  18. webbee-0.3.19/src/webbee/tui.py +1067 -0
  19. {webbee-0.3.17 → webbee-0.3.19}/tests/test_commands.py +34 -0
  20. webbee-0.3.19/tests/test_home.py +430 -0
  21. webbee-0.3.19/tests/test_reflow.py +85 -0
  22. {webbee-0.3.17 → webbee-0.3.19}/tests/test_render.py +28 -0
  23. {webbee-0.3.17 → webbee-0.3.19}/tests/test_repl.py +961 -1
  24. webbee-0.3.19/tests/test_sizing.py +30 -0
  25. webbee-0.3.19/tests/test_slots.py +338 -0
  26. webbee-0.3.19/tests/test_tabs.py +280 -0
  27. {webbee-0.3.17 → webbee-0.3.19}/tests/test_todo_panel.py +52 -0
  28. webbee-0.3.19/tests/test_tui.py +4600 -0
  29. webbee-0.3.17/src/webbee/__init__.py +0 -1
  30. webbee-0.3.17/src/webbee/output_pane.py +0 -238
  31. webbee-0.3.17/src/webbee/repl.py +0 -523
  32. webbee-0.3.17/src/webbee/tui.py +0 -657
  33. webbee-0.3.17/tests/test_tui.py +0 -1759
  34. {webbee-0.3.17 → webbee-0.3.19}/.github/workflows/publish.yml +0 -0
  35. {webbee-0.3.17 → webbee-0.3.19}/.gitignore +0 -0
  36. {webbee-0.3.17 → webbee-0.3.19}/LICENSE +0 -0
  37. {webbee-0.3.17 → webbee-0.3.19}/README.md +0 -0
  38. {webbee-0.3.17 → webbee-0.3.19}/install.sh +0 -0
  39. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/account.py +0 -0
  40. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/banner_art.py +0 -0
  41. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/checkpoints.py +0 -0
  42. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/cli.py +0 -0
  43. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/clipboard.py +0 -0
  44. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/coding_context.py +0 -0
  45. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/config.py +0 -0
  46. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/consent.py +0 -0
  47. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/details.py +0 -0
  48. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/events.py +0 -0
  49. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/frames.py +0 -0
  50. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/http.py +0 -0
  51. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/intel/__init__.py +0 -0
  52. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/intel/chunker.py +0 -0
  53. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/intel/embed.py +0 -0
  54. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/intel/graph.py +0 -0
  55. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/intel/indexer.py +0 -0
  56. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/intel/models.py +0 -0
  57. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/intel/query.py +0 -0
  58. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/intel/service.py +0 -0
  59. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/intel/store.py +0 -0
  60. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/intel/vectors.py +0 -0
  61. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/intel/watch.py +0 -0
  62. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/remote.py +0 -0
  63. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/repo.py +0 -0
  64. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/session.py +0 -0
  65. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/sessions.py +0 -0
  66. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/steer.py +0 -0
  67. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/stream.py +0 -0
  68. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/thread.py +0 -0
  69. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/tokens.py +0 -0
  70. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/tools.py +0 -0
  71. {webbee-0.3.17 → webbee-0.3.19}/src/webbee/update.py +0 -0
  72. {webbee-0.3.17 → webbee-0.3.19}/tests/__init__.py +0 -0
  73. {webbee-0.3.17 → webbee-0.3.19}/tests/test_account.py +0 -0
  74. {webbee-0.3.17 → webbee-0.3.19}/tests/test_checkpoints.py +0 -0
  75. {webbee-0.3.17 → webbee-0.3.19}/tests/test_cli.py +0 -0
  76. {webbee-0.3.17 → webbee-0.3.19}/tests/test_clipboard.py +0 -0
  77. {webbee-0.3.17 → webbee-0.3.19}/tests/test_config.py +0 -0
  78. {webbee-0.3.17 → webbee-0.3.19}/tests/test_cpc_contract_stable.py +0 -0
  79. {webbee-0.3.17 → webbee-0.3.19}/tests/test_details.py +0 -0
  80. {webbee-0.3.17 → webbee-0.3.19}/tests/test_events.py +0 -0
  81. {webbee-0.3.17 → webbee-0.3.19}/tests/test_freeze_fix.py +0 -0
  82. {webbee-0.3.17 → webbee-0.3.19}/tests/test_intel_chunker.py +0 -0
  83. {webbee-0.3.17 → webbee-0.3.19}/tests/test_intel_embed.py +0 -0
  84. {webbee-0.3.17 → webbee-0.3.19}/tests/test_intel_graph.py +0 -0
  85. {webbee-0.3.17 → webbee-0.3.19}/tests/test_intel_indexer.py +0 -0
  86. {webbee-0.3.17 → webbee-0.3.19}/tests/test_intel_no_numpy.py +0 -0
  87. {webbee-0.3.17 → webbee-0.3.19}/tests/test_intel_query.py +0 -0
  88. {webbee-0.3.17 → webbee-0.3.19}/tests/test_intel_service.py +0 -0
  89. {webbee-0.3.17 → webbee-0.3.19}/tests/test_intel_store.py +0 -0
  90. {webbee-0.3.17 → webbee-0.3.19}/tests/test_intel_vectors.py +0 -0
  91. {webbee-0.3.17 → webbee-0.3.19}/tests/test_intel_watch.py +0 -0
  92. {webbee-0.3.17 → webbee-0.3.19}/tests/test_marathon.py +0 -0
  93. {webbee-0.3.17 → webbee-0.3.19}/tests/test_packaging.py +0 -0
  94. {webbee-0.3.17 → webbee-0.3.19}/tests/test_repo.py +0 -0
  95. {webbee-0.3.17 → webbee-0.3.19}/tests/test_session.py +0 -0
  96. {webbee-0.3.17 → webbee-0.3.19}/tests/test_sessions.py +0 -0
  97. {webbee-0.3.17 → webbee-0.3.19}/tests/test_steer.py +0 -0
  98. {webbee-0.3.17 → webbee-0.3.19}/tests/test_stream.py +0 -0
  99. {webbee-0.3.17 → webbee-0.3.19}/tests/test_thread.py +0 -0
  100. {webbee-0.3.17 → webbee-0.3.19}/tests/test_tokens.py +0 -0
  101. {webbee-0.3.17 → webbee-0.3.19}/tests/test_tools.py +0 -0
  102. {webbee-0.3.17 → webbee-0.3.19}/tests/test_tui_hardening.py +0 -0
  103. {webbee-0.3.17 → webbee-0.3.19}/tests/test_update.py +0 -0
  104. {webbee-0.3.17 → webbee-0.3.19}/tests/test_version.py +0 -0
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.19
4
+
5
+ The browser wave: Webbee Code becomes a browser inside your terminal.
6
+
7
+ - A tab bar lives at the top: ◆ Home first, your sessions after it, each with
8
+ a live status glyph (▶ working · ⚠ waiting for your approval · ○ idle).
9
+ Click a tab or press Alt+0..9 to switch; Ctrl+T jumps to Home; Ctrl+W or
10
+ /close closes a tab (the run keeps living server-side); the ✕ on a tab
11
+ closes exactly that tab.
12
+ - Home is a new-tab page: your identity and plan, every open tab, the current
13
+ repo's intelligence and checkpoints, remote-control state and updates —
14
+ filled live, resize-aware. Start typing on Home and a new session tab opens
15
+ with your message. A fresh install lands on Home; an ongoing conversation
16
+ lands back in its session tab.
17
+ - Every tab is its own world: transcript, queue, todos, approvals, mode and
18
+ input history never leak between tabs. Background tabs keep streaming; a
19
+ tab waiting for your approval shows a ⚠ badge from anywhere; a running
20
+ background turn is protected from a stray Ctrl+D.
21
+
22
+ ## 0.3.18
23
+
24
+ The adaptive wave: the terminal finally behaves like a real app.
25
+
26
+ - Stretch or shrink the window and the WHOLE transcript re-wraps live to the
27
+ new size — history included, splash re-centered, nothing clipped, no dead
28
+ margins. Panels, the input box and text truncations now scale as
29
+ proportions of your screen, not fixed character counts.
30
+ - Select text with the mouse and drag past the edge — the transcript
31
+ auto-scrolls under your selection, keeps selecting while you hold at the
32
+ edge, and finishes the copy even if you release outside the pane. Scrolling
33
+ mid-selection no longer corrupts what you copy, and a lost release can
34
+ never hijack your next click or your clipboard.
35
+
3
36
  ## 0.3.17
4
37
 
5
38
  Bulletproof core (W1): a marathon can no longer be killed by transport, the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.17
3
+ Version: 0.3.19
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.17"
3
+ version = "0.3.19"
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.19"
@@ -60,10 +60,17 @@ def _default_intel_factory(cfg, workspace: str):
60
60
  return IntelService(root, compute_repo_key(root), cache_dir=cfg.cache_dir)
61
61
 
62
62
 
63
- async def replay_thread(cfg, token_provider, sink) -> None:
63
+ async def replay_thread(cfg, token_provider, sink) -> int:
64
64
  """Boot replay of the durable per-user thread (Task 9): best-effort,
65
65
  entirely swallowed on any failure -- history is a nice-to-have,
66
- never a boot blocker (network down, no such session yet, etc.)."""
66
+ never a boot blocker (network down, no such session yet, etc.).
67
+
68
+ Returns the count of replayed DISPLAY messages (FIX7e, W4a final review
69
+ — land-on-Home): 0 on a fresh/empty thread, or on ANY failure/skip --
70
+ the never-raise contract is unchanged, this is purely an additional
71
+ signal. repl's dock boot uses it to decide whether to land on the
72
+ session tab (there's something to show) or on Home (nothing yet --
73
+ the new-tab dashboard is the more useful first screen)."""
67
74
  try:
68
75
  from imperal_mcp.client import ImperalClient
69
76
  from webbee.thread import (conversational_text, fetch_recent_thread,
@@ -80,8 +87,9 @@ async def replay_thread(cfg, token_provider, sink) -> None:
80
87
  _shown += 1
81
88
  if _shown:
82
89
  sink.note("— live —")
90
+ return _shown
83
91
  except Exception:
84
- pass # replay is best-effort; never block boot
92
+ return 0 # replay is best-effort; never block boot
85
93
 
86
94
 
87
95
  async def start_intel(cfg, workspace: str, intel_factory):
@@ -106,3 +114,23 @@ async def start_shadow(cfg, workspace: str, shadow_factory):
106
114
  return await asyncio.to_thread(shadow_factory or _default_shadow_factory, cfg, workspace)
107
115
  except Exception:
108
116
  return None
117
+
118
+
119
+ async def boot_workspace(cfg, workspace: str, intel_factory, shadow_factory) -> dict:
120
+ """Per-WORKSPACE boot phase (W4a boot split, map §6): everything same-
121
+ repo session slots SHARE -- intel + its watcher task, the reversibility
122
+ shadow, and the cached git branch (subprocess.run, off the event loop --
123
+ only /status reads it, recomputing per input line froze the dock).
124
+ Called once per distinct repo root; webbee.slots.WorkspaceResources
125
+ caches the returned bundle so a second slot opened on the SAME root
126
+ reuses it instead of re-booting (re-indexing, a second watcher, a
127
+ second git subprocess)."""
128
+ if cfg.intel_enabled:
129
+ # Guarded off-loop build + watcher; any failure degrades to
130
+ # intel=None, never crashes the boot (start_intel above).
131
+ intel, watcher_task = await start_intel(cfg, workspace, intel_factory)
132
+ else:
133
+ intel, watcher_task = None, None
134
+ shadow = await start_shadow(cfg, workspace, shadow_factory)
135
+ git_branch = await asyncio.to_thread(_git_branch, workspace)
136
+ return {"intel": intel, "watcher_task": watcher_task, "shadow": shadow, "git_branch": git_branch}
@@ -10,6 +10,10 @@ _HELP = """Commands:
10
10
  /mode [default|plan|autopilot] consent mode (no arg — show current)
11
11
  /cost (=/usage) tokens + credits this session
12
12
  /status cwd · git · surface · tokens · version
13
+ /new [path] open a new tab — a session in path (default: cwd)
14
+ /tab N switch to tab N (see /tabs for the numbers)
15
+ /close close the active tab (the run keeps going server-side)
16
+ /tabs list open tabs
13
17
  /queue [clear] messages queued while Webbee works (clear drops them all)
14
18
  /steps [N] last turn's steps; N expands one (also: Up/Down + Enter)
15
19
  /checkpoints the reversibility time machine — list workspace checkpoints
@@ -80,6 +84,18 @@ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
80
84
  return SlashResult(handled=True, action="sessions")
81
85
  if cmd == "/logout-others":
82
86
  return SlashResult(handled=True, action="logout_others")
87
+ if cmd == "/new":
88
+ return SlashResult(handled=True, action="new_tab", arg=(" ".join(args) if args else ""))
89
+ if cmd == "/tab":
90
+ # N's validity depends on how many tabs are OPEN right now — CommandContext
91
+ # doesn't carry the slot list (same split as /sessions revoke <#>, which
92
+ # forwards the raw index and lets the repl validate against its own
93
+ # live state); the repl reports "no such tab" for a bad/missing N.
94
+ return SlashResult(handled=True, action="tab_switch", arg=(args[0] if args else ""))
95
+ if cmd == "/close":
96
+ return SlashResult(handled=True, action="tab_close")
97
+ if cmd == "/tabs":
98
+ return SlashResult(handled=True, action="tabs_list")
83
99
  if cmd == "/queue":
84
100
  # ctx.queued is the live deque's snapshot (threaded by the repl the
85
101
  # same way /status reads session state); the repl clears the actual
@@ -0,0 +1,290 @@
1
+ """The Home tab — the dock's new-tab page (W4a Task 6). Slot 0 is always
2
+ Home (`SessionSlot(kind="home", sink=None, agent=None)`, wiring map §1): its
3
+ own pane holds a live, best-effort DASHBOARD instead of a session transcript
4
+ — identity, open tabs, repo intelligence, system state — filled
5
+ asynchronously by `fill_home` so switching to Home (or booting) never blocks
6
+ on a network call. Every print goes through `slot.pane.console` (the SAME
7
+ RecordingConsole every session slot uses — W2 reflow re-centers on resize
8
+ for free, no Home-specific sizing code needed). Zero new server logic: every
9
+ data source below is an EXISTING reader (account.py / intel / checkpoints /
10
+ remote.py / update.py) — this module only arranges what they already return.
11
+
12
+ Rendering is append-only (Rich prints into a StringIO, never edits a
13
+ previous line — see output_pane.py), so "patch tiles as data lands" means
14
+ clear() + a full repaint each time a new piece of data arrives, not
15
+ in-place editing of one region: `fill_home` clears, paints the instant
16
+ `render_skeleton` shell, then re-clears and re-paints the WHOLE dashboard
17
+ (skeleton + whatever tiles have real data so far, "…" for the rest) after
18
+ each async fetch completes — bounded (four repaints total: the instant
19
+ skeleton, then one per async tile) and cheap enough because the pane's own
20
+ records ring is what makes a full repaint affordable at all."""
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import os
25
+ import time
26
+
27
+ from rich.align import Align
28
+ from rich.text import Text
29
+
30
+ from webbee.banner_art import WEBBEE_CODE
31
+ from webbee.render import _ACCENT, _BEE
32
+
33
+ HOME_HINT = "type a task — a new tab opens · Ctrl+T here anytime · Alt+N switch"
34
+ _SECTIONS = ("Identity", "Tabs", "Repo", "System")
35
+
36
+
37
+ def _mask_email(email: str) -> str:
38
+ """PII-safe display of an email: only the FIRST character of the local
39
+ part and of the domain survive (`valentin@webhostmost.com` ->
40
+ `v•••@w•••`) — enough to recognize your OWN account at a glance, nothing
41
+ an onlooker could read off a shared screen. Empty input -> empty output;
42
+ a malformed value with no `@` gets the same first-char+mask treatment
43
+ applied to the whole string, so it still can't leak past one character."""
44
+ email = (email or "").strip()
45
+ if not email:
46
+ return ""
47
+ if "@" not in email:
48
+ return f"{email[0]}•••"
49
+ local, _, domain = email.partition("@")
50
+ local_mask = f"{local[0]}•••" if local else "•••"
51
+ domain_mask = f"{domain[0]}•••" if domain else "•••"
52
+ return f"{local_mask}@{domain_mask}"
53
+
54
+
55
+ def _render_header(console) -> None:
56
+ console.print()
57
+ console.print(Align.center(Text(WEBBEE_CODE, style=f"bold {_BEE}")))
58
+ console.print(Align.center(Text("◆ Home", style=f"bold {_ACCENT}")))
59
+ console.print()
60
+
61
+
62
+ def _render_hint(console) -> None:
63
+ console.print()
64
+ console.print(Align.center(Text(HOME_HINT, style="dim")))
65
+ console.print()
66
+
67
+
68
+ def render_skeleton(console, width: int) -> None:
69
+ """The instant, data-free shell: the logo, `◆ Home`, the four section
70
+ headers (each a dim placeholder line) and the hint. ZERO awaits — called
71
+ synchronously the moment `fill_home` starts, so switching to Home (or
72
+ booting) never shows a blank pane while the async tiles below are still
73
+ in flight. `width` mirrors the other renderers' signatures for parity;
74
+ `Align.center` itself measures the console's LIVE width at print time
75
+ (W2 replay-safe, same vocabulary as `render.RichSink.welcome`), so this
76
+ function has no direct use for it."""
77
+ _render_header(console)
78
+ for name in _SECTIONS:
79
+ console.print(Text(f" {name}", style=f"bold {_BEE}"))
80
+ console.print(Text(" …", style="dim"))
81
+ console.print()
82
+ _render_hint(console)
83
+
84
+
85
+ def render_identity(console, account) -> None:
86
+ """Nickname prominent, plan next, email masked last — the raw address
87
+ (PII) never prints. `account is None` (not signed in, or the fetch is
88
+ still pending / failed) renders the same neutral placeholder either
89
+ way — Home can't and shouldn't distinguish "loading" from "offline" for
90
+ the person looking at it."""
91
+ console.print(Text(" Identity", style=f"bold {_BEE}"))
92
+ if account is None or not getattr(account, "signed_in", False):
93
+ console.print(Text(" …", style="dim"))
94
+ console.print()
95
+ return
96
+ nickname = getattr(account, "nickname", "") or ""
97
+ headline = f"@{nickname}" if nickname else "(no nickname set)"
98
+ console.print(Text(f" {headline}", style="bold white"))
99
+ plan = getattr(account, "plan", "") or ""
100
+ if plan:
101
+ status = getattr(account, "plan_status", "") or ""
102
+ tag = f" ({status})" if status and status != "active" else ""
103
+ console.print(Text(f" {plan} plan{tag}", style="dim"))
104
+ email = getattr(account, "email", "") or ""
105
+ if email:
106
+ console.print(Text(f" {_mask_email(email)}", style="dim"))
107
+ console.print()
108
+
109
+
110
+ def render_slots_tile(console, slots) -> None:
111
+ """Every OPEN session tab (Home never lists itself), live status glyphs
112
+ (`slot.status_glyph()`) and the always-true hint for how to get one
113
+ started. `slots` is a `SlotManager` — read live, never a snapshot, so
114
+ this always reflects the tabs that exist right now."""
115
+ console.print(Text(" Tabs", style=f"bold {_BEE}"))
116
+ sessions = [(i, s) for i, s in enumerate(slots.slots) if s.kind == "session"]
117
+ if not sessions:
118
+ console.print(Text(" no tabs open yet", style="dim"))
119
+ else:
120
+ active_idx = slots.active_idx
121
+ for idx, s in sessions:
122
+ marker = "●" if idx == active_idx else "○"
123
+ console.print(Text(f" {marker} {idx} {s.label} {s.status_glyph()}", style="white"))
124
+ console.print(Text(" + Ctrl+T / type to start", style="dim"))
125
+ console.print()
126
+
127
+
128
+ def render_repo_tile(console, profile, branch: str, checkpoints: str | None) -> None:
129
+ """Repo intelligence for whichever workspace `fill_home` picked (the
130
+ active session tab, or the most recently opened one). `profile` falsy
131
+ covers "no repo yet", "intel disabled" and "the fetch failed" alike —
132
+ all three render the same neutral placeholder; there is nothing actionable
133
+ to tell the difference between them."""
134
+ console.print(Text(" Repo", style=f"bold {_BEE}"))
135
+ if not profile:
136
+ console.print(Text(" …", style="dim"))
137
+ console.print()
138
+ return
139
+ langs = ", ".join(sorted((profile.get("languages") or {}).keys())) or "—"
140
+ files = profile.get("file_count", 0)
141
+ console.print(Text(f" branch {branch or '-'} · {files} files · {langs}", style="white"))
142
+ if checkpoints:
143
+ head = next((ln for ln in checkpoints.splitlines() if ln.strip()), "")
144
+ if head:
145
+ console.print(Text(f" {head}", style="dim"))
146
+ console.print()
147
+
148
+
149
+ def render_system_tile(console, *, remote_desc: str | None, update_notice: str | None) -> None:
150
+ """Remote-control status (only meaningful once a coding turn has run at
151
+ least once — `fill_home` passes `None` otherwise, a different state from
152
+ "the fetch failed" but rendered the same, since neither is actionable
153
+ from Home) plus a one-line update notice, when there is one."""
154
+ console.print(Text(" System", style=f"bold {_BEE}"))
155
+ line = remote_desc if remote_desc else "remote control: no live session yet"
156
+ console.print(Text(f" {line}", style="dim"))
157
+ if update_notice:
158
+ console.print(Text(f" {update_notice}", style="dim"))
159
+ console.print()
160
+
161
+
162
+ def is_stale(slot, now: float, ttl: float = 300.0) -> bool:
163
+ """PURE. The switch-to-Home refill gate: a slot that has never been
164
+ filled (`_last_fill` at its `0.0` default) is ALWAYS stale, regardless
165
+ of how small `now` itself is (e.g. moments after process start, when
166
+ `time.monotonic()` hasn't yet ticked past `ttl`) — "never filled" and
167
+ "filled too long ago" both mean the same thing here: go fill it."""
168
+ last = getattr(slot, "_last_fill", 0.0)
169
+ if last <= 0.0:
170
+ return True
171
+ return (now - last) > ttl
172
+
173
+
174
+ def _pick_session_slot(slots):
175
+ """Which session tab Home's repo/system tiles describe: the ACTIVE slot
176
+ when it's itself a real session (the tab you're actually looking at is
177
+ the most relevant one — true right after boot, when Home's own fill runs
178
+ while the first session tab is active); otherwise the most recently
179
+ OPENED session tab; `None` when no session tab exists at all (every tab
180
+ closed — the kernel run itself survives regardless, browser-tab model)."""
181
+ active = slots.active()
182
+ if active.kind == "session":
183
+ return active
184
+ for s in reversed(slots.slots):
185
+ if s.kind == "session":
186
+ return s
187
+ return None
188
+
189
+
190
+ async def fill_home(slot, *, cfg, token_provider, slots, account_fetcher,
191
+ sessions_client, resources, version: str) -> None:
192
+ """Paint Home's dashboard: the instant skeleton, then each tile
193
+ best-effort, in order (identity, tabs, repo, system) — a full repaint
194
+ after every step so real data replaces the "…" placeholder as it lands
195
+ (append-only console — see the module docstring, no live-editing one
196
+ region). Self-guarded against overlap (`slot._filling`): a stale
197
+ switch-to-Home while a boot fill is still running is a safe no-op, never
198
+ a second concurrent fetch. `slot._last_fill` (monotonic) is stamped on
199
+ the way out regardless of how it went, so a failed run still counts as
200
+ "tried just now" rather than retrying on every single switch.
201
+ `sessions_client` is accepted for interface/signature parity with the
202
+ wiring map's reachable-data-sources list (§5) but unused here — the
203
+ multi-surface "other devices" list is a different concept from this
204
+ tile's OWN dock tabs; a wallet/activity/connections tile is explicitly
205
+ W5 (map §5: no client wrappers exist for those yet)."""
206
+ if slot._filling:
207
+ return
208
+ slot._filling = True
209
+ console = slot.pane.console
210
+ width = console.width
211
+
212
+ account = None
213
+ profile, branch, checkpoints = None, "-", None
214
+ remote_desc, update_notice = None, None
215
+
216
+ def _repaint() -> None:
217
+ try:
218
+ console.clear()
219
+ render_skeleton(console, width)
220
+ except Exception:
221
+ pass
222
+ for render_fn, args, kwargs in (
223
+ (render_identity, (account,), {}),
224
+ (render_slots_tile, (slots,), {}),
225
+ (render_repo_tile, (profile, branch, checkpoints), {}),
226
+ (render_system_tile, (), {"remote_desc": remote_desc, "update_notice": update_notice}),
227
+ ):
228
+ try:
229
+ render_fn(console, *args, **kwargs)
230
+ except Exception:
231
+ pass
232
+ try:
233
+ slot.pane.notify()
234
+ except Exception:
235
+ pass
236
+
237
+ try:
238
+ _repaint() # instant shell — no data yet
239
+
240
+ try:
241
+ account = await account_fetcher(cfg, token_provider)
242
+ except Exception:
243
+ account = None
244
+ _repaint()
245
+
246
+ try:
247
+ picked = _pick_session_slot(slots)
248
+ ws = picked.workspace if picked is not None else slot.workspace
249
+ bundle = resources.get(ws) or {}
250
+ branch = bundle.get("git_branch", "-")
251
+ intel = bundle.get("intel")
252
+ shadow = bundle.get("shadow")
253
+ if intel is not None:
254
+ try:
255
+ profile = intel.repo_profile()
256
+ except Exception:
257
+ profile = None
258
+ if shadow is not None:
259
+ try:
260
+ checkpoints = await asyncio.to_thread(shadow.describe)
261
+ except Exception:
262
+ checkpoints = None
263
+ except Exception:
264
+ picked = None
265
+ _repaint()
266
+
267
+ try:
268
+ sid = getattr(getattr(picked, "agent", None), "session_id", "") if picked is not None else ""
269
+ if sid:
270
+ from webbee import remote as _remote
271
+ try:
272
+ state = await _remote.get_remote(cfg, token_provider, sid)
273
+ remote_desc = _remote.describe(state)
274
+ except Exception:
275
+ remote_desc = None
276
+ except Exception:
277
+ remote_desc = None
278
+ try:
279
+ from pathlib import Path
280
+
281
+ from webbee.update import check_for_update, default_fetch
282
+ cache = Path(os.path.expanduser("~/.cache/webbee/update.json"))
283
+ update_notice = await asyncio.to_thread(
284
+ check_for_update, version, cache_path=cache, now=time.time(), fetch=default_fetch)
285
+ except Exception:
286
+ update_notice = None
287
+ _repaint()
288
+ finally:
289
+ slot._last_fill = time.monotonic()
290
+ slot._filling = False