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.
- {webbee-0.3.35 → webbee-0.3.36}/CHANGELOG.md +27 -0
- {webbee-0.3.35 → webbee-0.3.36}/PKG-INFO +1 -1
- {webbee-0.3.35 → webbee-0.3.36}/pyproject.toml +1 -1
- webbee-0.3.36/src/webbee/__init__.py +1 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/commands.py +6 -1
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/home.py +8 -4
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/home_view.py +104 -4
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/repl.py +89 -11
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/tui.py +115 -11
- webbee-0.3.36/src/webbee/update.py +82 -0
- webbee-0.3.36/tests/test_terminal_ux_0336.py +481 -0
- webbee-0.3.35/src/webbee/__init__.py +0 -1
- webbee-0.3.35/src/webbee/update.py +0 -52
- {webbee-0.3.35 → webbee-0.3.36}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/.gitignore +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/LICENSE +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/README.md +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/docs/superpowers/plans/2026-07-21-interactive-home-dashboard.md +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/install.sh +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/account.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/active_sessions.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/boot.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/cli.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/clipboard_read.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/config.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/consent.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/details.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/events.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/frames.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/http.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/instance_lock.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/mode_store.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/newtab_mode.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/queue_panel.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/reflow.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/remote.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/render.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/repo.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/selection.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/session.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/sessions.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/sizing.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/slots.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/steer.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/stream.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/tabs.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/thread.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/todo_panel.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/tokens.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/tools.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/urlopen.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/wallet.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/src/webbee/worktrees.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/__init__.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/conftest.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_account.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_active_sessions.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_audit_2026_07_25_findings.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_cli.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_clipboard.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_clipboard_read.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_coding_context.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_commands.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_config.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_details.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_events.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_home.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_home_view.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_instance_lock.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_query.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_service.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_store.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_marathon.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_mode_store.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_newtab_mode.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_packaging.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_reflow.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_render.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_repl.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_repo.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_session.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_sessions.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_sizing.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_slots.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_steer.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_stream.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_tabs.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_thread.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_todo_panel.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_tokens.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_tools.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_tui.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_update.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_urlopen.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_version.py +0 -0
- {webbee-0.3.35 → webbee-0.3.36}/tests/test_wallet.py +0 -0
- {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.
|
|
@@ -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
|
|
184
|
+
from webbee.update import check_update_status, default_fetch
|
|
185
185
|
cache = Path(os.path.expanduser("~/.cache/webbee/update.json"))
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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=
|
|
139
|
-
session_credits=
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
1045
|
-
#
|
|
1046
|
-
|
|
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}
|