webbee 0.3.35__tar.gz → 0.3.37__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 (128) hide show
  1. {webbee-0.3.35 → webbee-0.3.37}/CHANGELOG.md +60 -0
  2. {webbee-0.3.35 → webbee-0.3.37}/PKG-INFO +1 -1
  3. {webbee-0.3.35 → webbee-0.3.37}/pyproject.toml +1 -1
  4. webbee-0.3.37/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/active_sessions.py +79 -0
  6. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/commands.py +25 -2
  7. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/home.py +8 -4
  8. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/home_view.py +104 -4
  9. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/queue_panel.py +50 -3
  10. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/repl.py +315 -11
  11. webbee-0.3.37/src/webbee/tab_store.py +123 -0
  12. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/tui.py +225 -16
  13. webbee-0.3.37/src/webbee/update.py +82 -0
  14. webbee-0.3.37/tests/test_queue_manage_0337.py +138 -0
  15. webbee-0.3.37/tests/test_tab_restore_0337.py +248 -0
  16. webbee-0.3.37/tests/test_terminal_ux_0336.py +481 -0
  17. {webbee-0.3.35 → webbee-0.3.37}/tests/test_tui.py +2 -1
  18. webbee-0.3.37/tests/test_version_badge_corner.py +71 -0
  19. webbee-0.3.35/src/webbee/__init__.py +0 -1
  20. webbee-0.3.35/src/webbee/update.py +0 -52
  21. {webbee-0.3.35 → webbee-0.3.37}/.github/workflows/publish.yml +0 -0
  22. {webbee-0.3.35 → webbee-0.3.37}/.gitignore +0 -0
  23. {webbee-0.3.35 → webbee-0.3.37}/LICENSE +0 -0
  24. {webbee-0.3.35 → webbee-0.3.37}/README.md +0 -0
  25. {webbee-0.3.35 → webbee-0.3.37}/docs/superpowers/plans/2026-07-21-interactive-home-dashboard.md +0 -0
  26. {webbee-0.3.35 → webbee-0.3.37}/install.sh +0 -0
  27. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/account.py +0 -0
  28. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/banner_art.py +0 -0
  29. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/boot.py +0 -0
  30. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/checkpoints.py +0 -0
  31. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/cli.py +0 -0
  32. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/clipboard.py +0 -0
  33. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/clipboard_read.py +0 -0
  34. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/coding_context.py +0 -0
  35. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/config.py +0 -0
  36. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/consent.py +0 -0
  37. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/details.py +0 -0
  38. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/events.py +0 -0
  39. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/frames.py +0 -0
  40. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/http.py +0 -0
  41. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/instance_lock.py +0 -0
  42. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/intel/__init__.py +0 -0
  43. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/intel/chunker.py +0 -0
  44. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/intel/embed.py +0 -0
  45. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/intel/graph.py +0 -0
  46. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/intel/indexer.py +0 -0
  47. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/intel/models.py +0 -0
  48. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/intel/query.py +0 -0
  49. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/intel/service.py +0 -0
  50. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/intel/store.py +0 -0
  51. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/intel/vectors.py +0 -0
  52. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/intel/watch.py +0 -0
  53. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/mode_store.py +0 -0
  54. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/newtab_mode.py +0 -0
  55. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/output_pane.py +0 -0
  56. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/reflow.py +0 -0
  57. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/remote.py +0 -0
  58. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/render.py +0 -0
  59. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/repo.py +0 -0
  60. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/selection.py +0 -0
  61. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/session.py +0 -0
  62. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/sessions.py +0 -0
  63. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/sizing.py +0 -0
  64. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/slots.py +0 -0
  65. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/steer.py +0 -0
  66. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/stream.py +0 -0
  67. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/tabs.py +0 -0
  68. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/thread.py +0 -0
  69. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/todo_panel.py +0 -0
  70. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/tokens.py +0 -0
  71. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/tools.py +0 -0
  72. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/urlopen.py +0 -0
  73. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/wallet.py +0 -0
  74. {webbee-0.3.35 → webbee-0.3.37}/src/webbee/worktrees.py +0 -0
  75. {webbee-0.3.35 → webbee-0.3.37}/tests/__init__.py +0 -0
  76. {webbee-0.3.35 → webbee-0.3.37}/tests/conftest.py +0 -0
  77. {webbee-0.3.35 → webbee-0.3.37}/tests/test_account.py +0 -0
  78. {webbee-0.3.35 → webbee-0.3.37}/tests/test_active_sessions.py +0 -0
  79. {webbee-0.3.35 → webbee-0.3.37}/tests/test_audit_2026_07_25_findings.py +0 -0
  80. {webbee-0.3.35 → webbee-0.3.37}/tests/test_checkpoints.py +0 -0
  81. {webbee-0.3.35 → webbee-0.3.37}/tests/test_cli.py +0 -0
  82. {webbee-0.3.35 → webbee-0.3.37}/tests/test_clipboard.py +0 -0
  83. {webbee-0.3.35 → webbee-0.3.37}/tests/test_clipboard_read.py +0 -0
  84. {webbee-0.3.35 → webbee-0.3.37}/tests/test_coding_context.py +0 -0
  85. {webbee-0.3.35 → webbee-0.3.37}/tests/test_commands.py +0 -0
  86. {webbee-0.3.35 → webbee-0.3.37}/tests/test_config.py +0 -0
  87. {webbee-0.3.35 → webbee-0.3.37}/tests/test_cpc_contract_stable.py +0 -0
  88. {webbee-0.3.35 → webbee-0.3.37}/tests/test_details.py +0 -0
  89. {webbee-0.3.35 → webbee-0.3.37}/tests/test_events.py +0 -0
  90. {webbee-0.3.35 → webbee-0.3.37}/tests/test_freeze_fix.py +0 -0
  91. {webbee-0.3.35 → webbee-0.3.37}/tests/test_home.py +0 -0
  92. {webbee-0.3.35 → webbee-0.3.37}/tests/test_home_view.py +0 -0
  93. {webbee-0.3.35 → webbee-0.3.37}/tests/test_instance_lock.py +0 -0
  94. {webbee-0.3.35 → webbee-0.3.37}/tests/test_intel_chunker.py +0 -0
  95. {webbee-0.3.35 → webbee-0.3.37}/tests/test_intel_embed.py +0 -0
  96. {webbee-0.3.35 → webbee-0.3.37}/tests/test_intel_graph.py +0 -0
  97. {webbee-0.3.35 → webbee-0.3.37}/tests/test_intel_indexer.py +0 -0
  98. {webbee-0.3.35 → webbee-0.3.37}/tests/test_intel_no_numpy.py +0 -0
  99. {webbee-0.3.35 → webbee-0.3.37}/tests/test_intel_query.py +0 -0
  100. {webbee-0.3.35 → webbee-0.3.37}/tests/test_intel_service.py +0 -0
  101. {webbee-0.3.35 → webbee-0.3.37}/tests/test_intel_store.py +0 -0
  102. {webbee-0.3.35 → webbee-0.3.37}/tests/test_intel_vectors.py +0 -0
  103. {webbee-0.3.35 → webbee-0.3.37}/tests/test_intel_watch.py +0 -0
  104. {webbee-0.3.35 → webbee-0.3.37}/tests/test_marathon.py +0 -0
  105. {webbee-0.3.35 → webbee-0.3.37}/tests/test_mode_store.py +0 -0
  106. {webbee-0.3.35 → webbee-0.3.37}/tests/test_newtab_mode.py +0 -0
  107. {webbee-0.3.35 → webbee-0.3.37}/tests/test_packaging.py +0 -0
  108. {webbee-0.3.35 → webbee-0.3.37}/tests/test_reflow.py +0 -0
  109. {webbee-0.3.35 → webbee-0.3.37}/tests/test_render.py +0 -0
  110. {webbee-0.3.35 → webbee-0.3.37}/tests/test_repl.py +0 -0
  111. {webbee-0.3.35 → webbee-0.3.37}/tests/test_repo.py +0 -0
  112. {webbee-0.3.35 → webbee-0.3.37}/tests/test_session.py +0 -0
  113. {webbee-0.3.35 → webbee-0.3.37}/tests/test_sessions.py +0 -0
  114. {webbee-0.3.35 → webbee-0.3.37}/tests/test_sizing.py +0 -0
  115. {webbee-0.3.35 → webbee-0.3.37}/tests/test_slots.py +0 -0
  116. {webbee-0.3.35 → webbee-0.3.37}/tests/test_steer.py +0 -0
  117. {webbee-0.3.35 → webbee-0.3.37}/tests/test_stream.py +0 -0
  118. {webbee-0.3.35 → webbee-0.3.37}/tests/test_tabs.py +0 -0
  119. {webbee-0.3.35 → webbee-0.3.37}/tests/test_thread.py +0 -0
  120. {webbee-0.3.35 → webbee-0.3.37}/tests/test_todo_panel.py +0 -0
  121. {webbee-0.3.35 → webbee-0.3.37}/tests/test_tokens.py +0 -0
  122. {webbee-0.3.35 → webbee-0.3.37}/tests/test_tools.py +0 -0
  123. {webbee-0.3.35 → webbee-0.3.37}/tests/test_tui_hardening.py +0 -0
  124. {webbee-0.3.35 → webbee-0.3.37}/tests/test_update.py +0 -0
  125. {webbee-0.3.35 → webbee-0.3.37}/tests/test_urlopen.py +0 -0
  126. {webbee-0.3.35 → webbee-0.3.37}/tests/test_version.py +0 -0
  127. {webbee-0.3.35 → webbee-0.3.37}/tests/test_wallet.py +0 -0
  128. {webbee-0.3.35 → webbee-0.3.37}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.37
4
+
5
+ Your tabs come back, the queue does what you tell it, and the version sits
6
+ where you asked for it.
7
+
8
+ - **Closing the terminal no longer throws your work away.** Three tabs of live
9
+ work used to become one empty tab on the next start. Your layout is
10
+ remembered per repo and restored on boot — and it is reconciled against what
11
+ is *actually* still running, so a tab that really ended comes back as a
12
+ labelled tab rather than a dead one pretending to be alive. An accidental
13
+ exit, Ctrl-D and a crash all remember equally. Autopilot is deliberately
14
+ never remembered: a mode that acts on its own has to be turned on each time.
15
+ - **A queue you can actually manage.** Every queued line now has its own **✕**,
16
+ so you can remove *any* item — previously the only way out was ↑, which
17
+ hijacked your input and could only ever reach the newest row, leaving item 1
18
+ of 3 stuck. `/queue drop N` and `/queue edit N` do the same from the keyboard
19
+ when your terminal reports no mouse. Editing an item *removes* it from the
20
+ queue as it moves into your input, so a line can never both sit in the queue
21
+ and still fire.
22
+ - **Version badge, bottom-right of the window.** Pinned to the corner of the
23
+ whole window rather than floating inside the toolbar's content. On a narrow
24
+ terminal the badge is dropped instead of truncating real information.
25
+ - **A live-session indicator that keeps telling you the truth.** The old boot
26
+ note fired once and scrolled away. The toolbar now shows "● live",
27
+ "● live · needs approval" or "○ N elsewhere", refreshed every 30 seconds from
28
+ what the server actually reports — and it stays empty when nothing is
29
+ running, so an idle dock gains no permanent noise.
30
+ - **✕, Ctrl+W and `/close` all really do close a tab** — they always shared one
31
+ teardown path; what was missing was saying so, and the busy-close note now
32
+ names all three ways out.
33
+ - Fixed two latent crashes that would only have fired on a click (a queue panel
34
+ and a row renderer were each used without being imported).
35
+
36
+ ## 0.3.36
37
+
38
+ Write on more than one line, and see what a session actually costs.
39
+
40
+ - **New line inside your prompt.** Press **Alt+Enter** — or **Shift+Enter** on
41
+ a modern terminal, or **Ctrl+J** anywhere at all — to add a line without
42
+ sending. Enter still sends, exactly as before. Three chords because no single
43
+ one is available in every terminal: whichever your setup supports, one of
44
+ them works. Continuation lines are marked with a dim ┊ gutter, so a
45
+ half-written message never looks like something already sent. Pasting
46
+ multi-line text now keeps its line breaks instead of firing off each line.
47
+ - **Spend is visible from every tab.** The status line at the bottom shows the
48
+ tokens and credits used in this session, in every tab. Home — which has no
49
+ session of its own — shows the running TOTAL across all your open tabs, both
50
+ in its status line and in the Wallet tile ("spent this session").
51
+ - **Version and freshness, bottom right of Home.** Your Webbee version sits in
52
+ the bottom-right corner. It says "up to date" only when we actually managed
53
+ to check; when we could not reach the release server it shows the plain
54
+ version rather than a reassurance we cannot back up. A newer release is
55
+ called out in bee-yellow with the version to upgrade to.
56
+ - **`/login` now works on Home.** Previously `/login`, `/logout`, `/sessions`,
57
+ `/logout-others` and `/cost` only worked in a session tab, which made no
58
+ sense — they act on your account, not on a session. They all work on Home
59
+ now, and Home shows a "Sign in to Imperal" button when you are signed out
60
+ instead of a blank space. `/cost` on Home reports the whole session's total.
61
+ - `/help` lists the keyboard shortcuts, including the new-line chords.
62
+
3
63
  ## 0.3.35
4
64
 
5
65
  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.37
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.37"
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.37"
@@ -39,6 +39,85 @@ async def fetch_active_sessions(cfg, token_provider, *, client=None) -> list[dic
39
39
  return []
40
40
 
41
41
 
42
+ def live_session_ids(sessions: list[dict]) -> set:
43
+ """PURE. The set of session ids the gateway currently reports as RUNNING.
44
+ One helper so the tab-restore reconciliation and the status indicator read
45
+ liveness from the SAME shape (0.3.37)."""
46
+ return {str(s.get("session_id") or "") for s in (sessions or [])
47
+ if str(s.get("session_id") or "")}
48
+
49
+
50
+ def session_indicator(sessions: list[dict], repo_key: str) -> str:
51
+ """PURE. The PERSISTENT live-session indicator for the toolbar (0.3.37).
52
+
53
+ Before this, a running Temporal workflow was mentioned exactly once, in a
54
+ boot note that scrolled away -- so after an accidental exit and reopen the
55
+ terminal silently re-attached with nothing on screen ever confirming that
56
+ it had seen a running session, or that one existed at all.
57
+
58
+ Returns a SHORT string for the status line, or "" when there is nothing
59
+ to say (no running sessions at all -- silence is correct then, the dock
60
+ must not grow permanent noise):
61
+ * this repo has a live session -> "● live"
62
+ * ...and it is parked on an approval -> "● live · needs approval"
63
+ * only OTHER repos have live ones -> "○ 2 elsewhere"
64
+ Honest by construction: it reports what the gateway's Temporal listing
65
+ actually says, never a guess.
66
+ """
67
+ suffix = f"-r{repo_key}"
68
+ own, own_parked, others = False, False, 0
69
+ for s in (sessions or []):
70
+ sid = str(s.get("session_id") or "")
71
+ if not sid:
72
+ continue
73
+ if sid.endswith(suffix):
74
+ own = True
75
+ if s.get("pending_consent"):
76
+ own_parked = True
77
+ else:
78
+ others += 1
79
+ if own:
80
+ return "● live · needs approval" if own_parked else "● live"
81
+ if others:
82
+ return f"○ {others} elsewhere"
83
+ return ""
84
+
85
+
86
+ def plan_tab_restore(saved: list, sessions: list[dict]) -> list:
87
+ """PURE. Reconcile the REMEMBERED tab layout (tab_store.load_tabs) against
88
+ the sessions Temporal reports as Running, and decide per tab what reopening
89
+ it means (0.3.37).
90
+
91
+ Each returned dict is the saved record plus:
92
+ * `attach`: True -> its workflow is STILL RUNNING; the tab must re-attach
93
+ and pick the live run up where it left off.
94
+ `attach`: False -> nothing is running under that id any more; restore
95
+ the tab's state (label/mode/draft) only.
96
+ * `pending_consent`: carried through from the live listing so a restored
97
+ tab can immediately show it is parked on an approval.
98
+
99
+ A saved tab with no session_id is legal (a tab you never sent anything in)
100
+ and simply restores as state-only. Never raises; unknown/extra keys on the
101
+ saved record are ignored rather than trusted.
102
+ """
103
+ live = {}
104
+ for s in (sessions or []):
105
+ sid = str(s.get("session_id") or "")
106
+ if sid:
107
+ live[sid] = s
108
+ out = []
109
+ for rec in (saved or []):
110
+ if not isinstance(rec, dict):
111
+ continue
112
+ sid = str(rec.get("session_id") or "")
113
+ s = live.get(sid)
114
+ item = dict(rec)
115
+ item["attach"] = bool(sid and s is not None)
116
+ item["pending_consent"] = bool((s or {}).get("pending_consent"))
117
+ out.append(item)
118
+ return out
119
+
120
+
42
121
  def boot_reattach_notice(sessions: list[dict], repo_key: str) -> list[str]:
43
122
  """Decide what, if anything, to tell the user about their OTHER running
44
123
  sessions once THIS terminal's own boot replay has finished. `sessions`
@@ -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)
@@ -106,6 +111,23 @@ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
106
111
  # same way /status reads session state); the repl clears the actual
107
112
  # deque on action="queue_clear" — the message here already carries
108
113
  # the honest drop count.
114
+ # 0.3.37: drop/edit ONE queued item by its 1-based /queue number --
115
+ # the KEYBOARD twin of the panel's per-row ✕ and click-to-edit, so
116
+ # managing a 3-deep queue works on terminals with no mouse reporting
117
+ # (tmux without mouse on, restrictive SSH clients) too. The repl owns
118
+ # the actual deque mutation; this layer only validates + routes.
119
+ if args and args[0].lower() in ("drop", "remove", "rm", "edit"):
120
+ verb = "queue_drop" if args[0].lower() != "edit" else "queue_edit"
121
+ if len(args) < 2:
122
+ return SlashResult(handled=True, action="queue",
123
+ message=f"Usage: /queue {args[0].lower()} <number> "
124
+ f"(see /queue for the numbers).")
125
+ raw = args[1]
126
+ if not raw.isdigit() or int(raw) < 1 or int(raw) > len(ctx.queued):
127
+ return SlashResult(handled=True, action="queue",
128
+ message=(f"No queued item #{raw}. "
129
+ f"Queue holds {len(ctx.queued)}."))
130
+ return SlashResult(handled=True, action=verb, arg=str(int(raw)))
109
131
  if args and args[0].lower() == "clear":
110
132
  n = len(ctx.queued)
111
133
  return SlashResult(handled=True, action="queue_clear",
@@ -117,7 +139,8 @@ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
117
139
  lines = "\n".join(f" {i}. {t}" for i, t in enumerate(ctx.queued, 1))
118
140
  return SlashResult(handled=True, action="queue",
119
141
  message=(f"Queued ({len(ctx.queued)}) — runs in order, "
120
- f"one per finished turn:\n{lines}"))
142
+ f"one per finished turn:\n{lines}\n"
143
+ f" /queue drop <n> · /queue edit <n> · /queue clear"))
121
144
  if cmd == "/steps":
122
145
  if args:
123
146
  return SlashResult(handled=True, action="step_detail", arg=args[0])
@@ -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,
@@ -68,6 +68,43 @@ def pull_item(pending, buf, index: int):
68
68
  return item
69
69
 
70
70
 
71
+ def drop_item(pending, index: int):
72
+ """The ONE queue-REMOVE implementation (0.3.37) — serves the per-row ✕ in
73
+ the panel. Deletes pending[index] WITHOUT touching the input buffer, so
74
+ removing item #1 of 3 no longer forces you to pull it into the input just
75
+ to get rid of it (before this, the only exit from the queue was
76
+ `pull_item`, which is edit-the-line, hijacks the buffer and — bound to ↑ —
77
+ only ever reached the NEWEST row).
78
+
79
+ Guards mirror `pull_item`: a stale index (the queue drained between render
80
+ and click) is ignored rather than raising or eating the wrong row. Returns
81
+ the removed item, or None when nothing was removed.
82
+ """
83
+ if not (0 <= index < len(pending)):
84
+ return None
85
+ item = pending[index]
86
+ del pending[index]
87
+ return item
88
+
89
+
90
+ def _drop_handler(drop, index: int, forward=None):
91
+ """One row's ✕ mouse handler: MOUSE_UP removes THAT queued item via
92
+ `drop(index)`. Same event discipline as `_item_handler` (MOUSE_UP only,
93
+ `forward` gets first refusal so a drag released here is not mistaken for
94
+ a click) — the difference is purely WHICH action fires, so a ✕ click can
95
+ never be confused with the row-body click that pulls for editing.
96
+ """
97
+ def _h(mouse_event):
98
+ from prompt_toolkit.mouse_events import MouseEventType
99
+ if forward is not None and forward(mouse_event):
100
+ return None
101
+ if mouse_event.event_type == MouseEventType.MOUSE_UP:
102
+ drop(index)
103
+ return None
104
+ return NotImplemented
105
+ return _h
106
+
107
+
71
108
  def _item_handler(pull, index: int, forward=None):
72
109
  """One row's mouse handler: MOUSE_UP (a click, not a drag/press) pulls
73
110
  THAT queued item into the input via `pull(index)`; every other event
@@ -109,7 +146,7 @@ def _toggle_handler(toggle, forward=None):
109
146
 
110
147
  def queue_fragments(pending, pull=None, width: int = 0, remote=None,
111
148
  collapsed=False, toggle=None, max_items=QP_MAX_ITEMS,
112
- forward=None):
149
+ forward=None, drop=None):
113
150
  """PURE builder: the panel as prompt_toolkit formatted text, re-invoked
114
151
  every redraw (same live mechanics as the toolbar) so every queue
115
152
  add/edit/drain shows at once. Layout, top→bottom = drain order (FIFO —
@@ -159,7 +196,9 @@ def queue_fragments(pending, pull=None, width: int = 0, remote=None,
159
196
  if collapsed:
160
197
  return frags
161
198
  if n:
162
- frags.append(("class:qp.item", " · ↑ edit last · click to edit"))
199
+ hint = (" · ↑ edit last · click to edit · ✕ remove" if drop is not None
200
+ else " · ↑ edit last · click to edit")
201
+ frags.append(("class:qp.item", hint))
163
202
  rstart = max(0, len(rem) - max_items)
164
203
  if rstart:
165
204
  frags.append(("class:qp.remote", f"\n … +{rstart} more"))
@@ -177,9 +216,17 @@ def queue_fragments(pending, pull=None, width: int = 0, remote=None,
177
216
  frags.append(("class:qp.item", f"\n … +{start} more"))
178
217
  for i in range(start, n):
179
218
  style = "class:qp.last" if i == n - 1 else "class:qp.item"
180
- row = "\n " + one_line(items[i], width - 4 if width > 0 else 0)
219
+ # 0.3.37: reserve the column so the row text truncates BEFORE the
220
+ # button rather than shoving it off-screen on a narrow terminal.
221
+ avail = (width - 4 - (2 if drop is not None else 0)) if width > 0 else 0
222
+ row = "\n " + one_line(items[i], avail)
181
223
  if pull is None:
182
224
  frags.append((style, row))
183
225
  else:
184
226
  frags.append((style, row, _item_handler(pull, i, forward)))
227
+ if drop is not None:
228
+ # Its OWN fragment + OWN handler: clicking ✕ removes the item,
229
+ # clicking the row body still pulls it for editing. Two adjacent
230
+ # targets, zero ambiguity.
231
+ frags.append(("class:qp.drop", " ✕", _drop_handler(drop, i, forward)))
185
232
  return frags