@vibelord/lanes 1.2.0 → 1.5.0
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.
- package/package.json +1 -1
- package/payload/lanes/README.md +7 -5
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +1091 -76
- package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
- package/payload/lanes/container/hooks/grok-hooks.json +12 -0
- package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
- package/payload/lanes/container/lane +55 -8
- package/payload/lanes/container/lane-mcp +120 -22
- package/payload/lanes/container/lane-signal +86 -23
- package/payload/lanes/container/lane_core.py +524 -36
- package/payload/lanes/container/throttle-pulse +456 -0
- package/payload/lanes/docs/ARCHITECTURE.md +357 -34
- package/payload/lanes/docs/DESIGN.md +13 -3
- package/payload/lanes/docs/INSTALL.md +47 -10
- package/payload/lanes/docs/RUNBOOK.md +761 -12
- package/payload/lanes/docs/USERGUIDE.md +421 -46
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/host-install.sh +39 -5
- package/payload/lanes/libexec/agent_state.py +604 -12
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/economy_poll.py +6 -2
- package/payload/lanes/libexec/harness_grok.py +742 -0
- package/payload/lanes/libexec/lanes_lib.py +143 -20
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +5 -0
- package/payload/lanes/libexec/notify.py +65 -5
- package/payload/lanes/libexec/sidebar.py +626 -25
- package/payload/lanes/libexec/statusd.py +530 -32
- package/payload/lanes/libexec/tokens.py +42 -9
- package/payload/lanes/libexec/update.py +171 -26
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +31 -3
- package/payload/lanes/site/site.example.conf +4 -0
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +86 -8
- package/payload/lanes/web/browser.js +2 -4
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.js +3 -2
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/manifest.json +5 -3
|
@@ -7,6 +7,7 @@ Fully usable from the keyboard (touch/mouse is a bonus):
|
|
|
7
7
|
↑/↓ j/k move · Enter/→/Space open · J/K open next/previous lane · / filter · Esc clear · g/G · r reload
|
|
8
8
|
2 / 3 open the lane under the cursor in the 2nd / 3rd viewport (wide view sessions) · x / × close (y, then Enter)
|
|
9
9
|
u unregister a session added with a (z within 10 s undoes it; a declared lane says which file to edit)
|
|
10
|
+
d shows the cursor lane's docs link in the footer (plan W10 W7) · i folds / unfolds the fleet block (W11)
|
|
10
11
|
? shows every key in the body until the next key · Tab / Shift-Tab open the next / previous lane that needs you
|
|
11
12
|
The keyboard goes back to the lane after every action (cockpit v3 S3): the sidebar and the lane share one keyboard, so
|
|
12
13
|
an action that ends — a close, a rename, a register, a cancel, Esc, r, closing the key sheet, a click that opened
|
|
@@ -48,6 +49,16 @@ focus first; the file itself is then sent by the upload helper on your machine (
|
|
|
48
49
|
there). Below it, when there is room, the strip row: the fleet economy line from the opt-in economy.json
|
|
49
50
|
(`$8.5k/16k 53% · F2 S1 · spill 76%`), else which OTHER workspaces need you (`◆1 Worlds`). Column 34 of a lane row is
|
|
50
51
|
one tail cell by priority: stale `?` > starving ⚠ > downgraded ↓ > mostly paid `$`.
|
|
52
|
+
Docs (plan W10 W7): while the workspace's lanes have published documents the idle strip says ` ↑ uploads · ▤ docs N
|
|
53
|
+
(U)`, one OSC 8 link to the docs page; the cursor row's name links to its lane's documents, and `d` shows that link in
|
|
54
|
+
the footer. The counts and the origin come in status.json (statusd): no stat or fork of the sidebar's own.
|
|
55
|
+
Fleet block (W11, spec 2026-10-11 §3): pinned above the upload strip, from status.json's `fleet` and `throttle` objects
|
|
56
|
+
only — Throttle in one row, an availability bar per provider (Anthropic, OpenAI and, since W12, Moonshot's Kimi),
|
|
57
|
+
the host and its top containers; a pane of 25 rows or more draws a rule and six rows, 18–24 three, fewer none, and the
|
|
58
|
+
lane list scrolls in the rest. `i` folds it to one line in the owner's profile (`look.fleet_collapsed`); with
|
|
59
|
+
LANES_FLEET_DASHBOARD_URL in site.conf the host row is one OSC 8 link to the dashboard. While its unfolded Throttle
|
|
60
|
+
row draws the money and every part after it (the same facts), the strip row's economy line yields to it; a row that
|
|
61
|
+
dropped `spill` or `429` to fit keeps the strip.
|
|
51
62
|
Truthful under load: a lane statusd could not observe keeps its last glyph, dimmed, with a `?`; the footer line warns
|
|
52
63
|
when the host is under pressure or statusd has fallen behind (age of $STATE/health, measured with our own clock).
|
|
53
64
|
Never frozen by an action (cockpit v3 S9): a close, `P`'s write and a new shell run on one worker thread (Worker); the
|
|
@@ -66,6 +77,7 @@ import sys
|
|
|
66
77
|
import termios
|
|
67
78
|
import threading
|
|
68
79
|
import time
|
|
80
|
+
import urllib.parse
|
|
69
81
|
from operator import itemgetter
|
|
70
82
|
from pathlib import Path
|
|
71
83
|
import tty
|
|
@@ -184,6 +196,35 @@ PROFILE_LOCK_S = 0.2
|
|
|
184
196
|
PROFILE_RETRY_S = 5.0
|
|
185
197
|
# What `p`, `h` and `H` say on a device nobody owns (cockpit v3 R5): pins and hidden lanes live in the owner's profile.
|
|
186
198
|
NO_PROFILE = 'no owner: lanes device claim'
|
|
199
|
+
# The docs cockpit (plan W10 W7, LNS-108). The docs page is lanes-docsd behind Caddy's `handle_path /lanes-docs/*`; a
|
|
200
|
+
# lane's documents are its index filtered `?ws=<ws>&lane=<lane>`. statusd publishes each lane's `docs: {n, at}` and the
|
|
201
|
+
# document's `web_origin` (`https://<name>:8443`) in status.json, which this sidebar already reads — no stat, no fork
|
|
202
|
+
# of its own. The links are OSC 8 hyperlinks (bytes, never cells): the upload strip's ` ↑ uploads · ▤ docs N (U)` to
|
|
203
|
+
# the page, the cursor row's name to its lane's documents, and the footer's `d` message to the same. Without an origin
|
|
204
|
+
# there is no link (a terminal needs an absolute URL) and `d` says the path alone. The origin is checked again here: it
|
|
205
|
+
# rides inside an OSC string, so only `https://<DNS host name>:8443` passes.
|
|
206
|
+
DOCS_PATH = '/lanes-docs/'
|
|
207
|
+
WEB_ORIGIN = re.compile(r'https://[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?:8443\Z', re.ASCII)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def docs_count(row):
|
|
211
|
+
"""A status.json lane row's document count (`docs.n`, plan W10 W7): a positive int, else 0."""
|
|
212
|
+
docs = row.get('docs') if isinstance(row, dict) else None
|
|
213
|
+
n = docs.get('n') if isinstance(docs, dict) else None
|
|
214
|
+
return n if isinstance(n, int) and not isinstance(n, bool) and n > 0 else 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def docs_url(origin, ws=None, lane=None):
|
|
218
|
+
"""The docs page on `origin` ('' = the path alone), filtered to a workspace and a lane when given:
|
|
219
|
+
`/lanes-docs/?ws=<ws>&lane=<lane>`, each value percent-quoted (nothing in it can end an OSC string)."""
|
|
220
|
+
query = '&'.join(f'{key}={urllib.parse.quote(value, safe="")}' for key, value in (('ws', ws), ('lane', lane)) if value)
|
|
221
|
+
return f'{origin}{DOCS_PATH}' + (f'?{query}' if query else '')
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def osc8(url, text):
|
|
225
|
+
"""`text` as ONE OSC 8 hyperlink to `url` (ST-terminated, no id); `text` as it is when there is no url. Costs
|
|
226
|
+
len(url) + 14 bytes and no cell: the budget gate's strip and cell walk read through it."""
|
|
227
|
+
return f'\033]8;;{url}\033\\{text}\033]8;;\033\\' if url else text
|
|
187
228
|
|
|
188
229
|
|
|
189
230
|
def shown_version(path):
|
|
@@ -267,6 +308,154 @@ def load_economy(doc):
|
|
|
267
308
|
lanes={k: v for k, v in lanes.items() if isinstance(v, dict)} if isinstance(lanes, dict) else {})
|
|
268
309
|
|
|
269
310
|
|
|
311
|
+
# ----------------------------------------------------------------- the fleet block (W11, spec 2026-10-11 §3)
|
|
312
|
+
# Pinned above the upload strip: Throttle in one line, an availability bar per provider, the host and its top
|
|
313
|
+
# containers — from status.json's `fleet` and `throttle` objects ONLY (statusd publishes both, spec §2.1/§2.2), so the
|
|
314
|
+
# block costs no stat, read or fork of the sidebar's own on a tick. A pane of FLEET_FULL rows or more draws a rule and
|
|
315
|
+
# six rows (W12 added the Kimi row: FLEET_FULL 24 -> 25, so the body a full block leaves keeps its W11 floor), one of
|
|
316
|
+
# FLEET_SHORT..FLEET_FULL − 1 rows three (Throttle, the worse provider, the host), a shorter one none; `i` folds it to
|
|
317
|
+
# one line (`look.fleet_collapsed` in the owner's profile). The lane list scrolls in the rest.
|
|
318
|
+
FLEET_SCHEMA = 1 # the `v` of both objects this reader understands (absent = 1; another is ignored)
|
|
319
|
+
FLEET_FULL, FLEET_SHORT = 25, 18
|
|
320
|
+
# Own-clock floors beneath the publishers' `stale` flags (DEF-1528's rule, as ECONOMY_STALE): statusd writes `fleet`
|
|
321
|
+
# every 10 s (a 12 s cycle at its slowest) and the pulse `throttle.at` at least every 60 s, which statusd flags past
|
|
322
|
+
# 60 s; a status.json that stopped changing (statusd frozen) still turns the block stale here.
|
|
323
|
+
FLEET_STALE_S, PULSE_STALE_S = 60, 90
|
|
324
|
+
BAR_CELLS = 10
|
|
325
|
+
# The pulse's pools, in row order, and the row's word: the vendor (W12, spec 2026-10-15 §6: Kimi Code's pool is
|
|
326
|
+
# `kimi`, its vendor Moonshot AI). No grok row until Throttle has an xAI pool (the 2026-10-14 xAI pool review's gates).
|
|
327
|
+
PROVIDERS = (('claude', 'anthropic'), ('codex', 'openai'), ('kimi', 'moonshot'))
|
|
328
|
+
# The pools whose overflow is money (fleet_money): Kimi has no paid overflow lane, so a kimi figure is never shown —
|
|
329
|
+
# the pulse records it as nulls (throttle-pulse MONEY_POOLS), and this reader would not show one if it did not.
|
|
330
|
+
MONEY_POOLS = ('claude', 'codex')
|
|
331
|
+
LABEL_CELLS = 9 # the label field of the Throttle and provider rows (`anthropic`), then one blank
|
|
332
|
+
# `429 +N`: how much the pools' upstream_429_total grew over the last SURGE_S seconds of pulse samples this sidebar
|
|
333
|
+
# saw — from the newest sample at or before that window's start; a counter that went down (a daemon restart) starts
|
|
334
|
+
# again. At most SURGE_SAMPLES are kept (the pulse writes at most every 10 s: 31 in five minutes).
|
|
335
|
+
SURGE_S, SURGE_SAMPLES = 300, 64
|
|
336
|
+
CPU_ATTENTION, MEM_ATTENTION = 0.95, 0.90
|
|
337
|
+
GIB = 1 << 30
|
|
338
|
+
SEVERITY = {'tone-urgent': 2, 'tone-attention': 1} # a row's worst part tones its lead mark; the fold's verdict
|
|
339
|
+
BAR_MARKS = ('bar-usable', 'bar-cooling', 'bar-benched', 'bar-dead')
|
|
340
|
+
# LANES_FLEET_DASHBOARD_URL, the operator's site.conf value (unset until an authenticated dashboard exists): the host
|
|
341
|
+
# row is then ONE OSC 8 link to it. Read at start and with the 30 s version recheck (recheck_version), never on a tick:
|
|
342
|
+
# the line as bin/lanes reads site.conf (SITE_LINE's characters, the last line wins), and only an https URL passes —
|
|
343
|
+
# it rides inside an OSC string, and a terminal needs an absolute URL.
|
|
344
|
+
SITE_CONF = os.path.join(os.path.expanduser('~'), '.config', 'lanes', 'site.conf')
|
|
345
|
+
SITE_CONF_MAX = 64 * 1024
|
|
346
|
+
DASHBOARD_LINE = re.compile(r'LANES_FLEET_DASHBOARD_URL=([A-Za-z0-9_.:@/-]*)')
|
|
347
|
+
DASHBOARD_URL = re.compile(r'https://[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?(?::[0-9]{1,5})?(?:/[A-Za-z0-9_.:@/-]*)?\Z',
|
|
348
|
+
re.ASCII)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def fleet_doc(value):
|
|
352
|
+
"""status.json's `fleet` / `throttle` object as the block reads it: a dict whose `v` is 1 (or absent), else {} —
|
|
353
|
+
an object of another version is ignored rather than half-read (load_economy's rule)."""
|
|
354
|
+
return value if isinstance(value, dict) and value.get('v', FLEET_SCHEMA) == FLEET_SCHEMA else {}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def tally(value):
|
|
358
|
+
"""A pool count: a non-negative int (a finite float is truncated), else 0."""
|
|
359
|
+
value = finite(value)
|
|
360
|
+
return max(0, int(value)) if value is not None else 0
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def half_up(numerator, denominator):
|
|
364
|
+
"""numerator / denominator (both ≥ 0, denominator > 0) rounded half up, in integers — never Python's half-to-even
|
|
365
|
+
(2.5 cells down, 3.5 up) and never a float that lands a hair under .5 (7 × 10 / 14 must give 5, not 4)."""
|
|
366
|
+
return (2 * numerator + denominator) // (2 * denominator)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def bar_segments(pool):
|
|
370
|
+
"""(usable, cooling, benched, dead) accounts of a pulse pool, exclusive and summing to its `configured`; None
|
|
371
|
+
without a configured count. The pulse's buckets overlap (spec §2.1: `cooling` is its remainder, `benched` the
|
|
372
|
+
model-benched accounts, usable or not), so the bar takes them in order, each capped by what is left: usable;
|
|
373
|
+
cooling; benched WITHIN cooling — a benched account the pool still counts usable reads usable, one it does not
|
|
374
|
+
reads benched instead of cooling; dead the rest (exhausted, auth failed, disabled, billing- or organization-blocked
|
|
375
|
+
or unhealthy: configured − usable − cooling)."""
|
|
376
|
+
configured = finite(pool.get('configured')) if isinstance(pool, dict) else None
|
|
377
|
+
if configured is None:
|
|
378
|
+
return None
|
|
379
|
+
configured = max(0, int(configured))
|
|
380
|
+
usable = min(tally(pool.get('usable')), configured)
|
|
381
|
+
cooling = min(tally(pool.get('cooling')), configured - usable)
|
|
382
|
+
benched = min(tally(pool.get('benched')), cooling)
|
|
383
|
+
return usable, cooling - benched, benched, configured - usable - cooling
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def bar_cells(segments):
|
|
387
|
+
"""Cells per segment (usable, cooling, benched, dead) of the BAR_CELLS-cell bar; they always sum to BAR_CELLS.
|
|
388
|
+
Usable is `usable × 10 / configured` rounded half up and held to 1..9 while some but not all accounts are usable:
|
|
389
|
+
the bar never shows none of a pool that serves, nor all of one that does not. The other three share what is left
|
|
390
|
+
by cumulative half-up rounding over the accounts that are not usable. Nothing configured is ten dead cells."""
|
|
391
|
+
usable, *rest = segments
|
|
392
|
+
configured = usable + sum(rest)
|
|
393
|
+
if not configured:
|
|
394
|
+
return (0, 0, 0, BAR_CELLS)
|
|
395
|
+
cells = half_up(usable * BAR_CELLS, configured)
|
|
396
|
+
if 0 < usable < configured:
|
|
397
|
+
cells = min(BAR_CELLS - 1, max(1, cells))
|
|
398
|
+
left, others = BAR_CELLS - cells, configured - usable
|
|
399
|
+
out, edge_before, seen = [cells], 0, 0
|
|
400
|
+
for value in rest:
|
|
401
|
+
seen += value
|
|
402
|
+
edge = half_up(seen * left, others) if others else 0
|
|
403
|
+
out.append(edge - edge_before)
|
|
404
|
+
edge_before = edge
|
|
405
|
+
return tuple(out)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def compact_gib(value):
|
|
409
|
+
"""Bytes as GiB in at most four cells: 0.4G, 8.4G, 14G, 123G (no trailing `.0`)."""
|
|
410
|
+
gib = max(0.0, float(value)) / GIB
|
|
411
|
+
return (f'{gib:.1f}' if gib < 9.95 else f'{gib:.0f}').removesuffix('.0') + 'G'
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def aged(doc, now, floor):
|
|
415
|
+
"""Whether a `fleet` / `throttle` object is a frozen picture: its publisher says so (`stale: true`), or its own `at`
|
|
416
|
+
is more than `floor` seconds behind `now` (OUR clock, DEF-1528). False for {} (nothing known, nothing claimed) and
|
|
417
|
+
without a numeric `at`."""
|
|
418
|
+
if not doc:
|
|
419
|
+
return False
|
|
420
|
+
at = number(doc.get('at'))
|
|
421
|
+
return doc.get('stale') is True or (at is not None and now - at > floor)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def finite(value):
|
|
425
|
+
"""number(value) when it is also finite (a NaN or an infinity in published JSON is no fact), else None."""
|
|
426
|
+
value = number(value)
|
|
427
|
+
return value if value is not None and -INF < value < INF else None
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def whole(value):
|
|
431
|
+
"""A finite value ≥ 0 rounded half up to an int (tenths first, so 37.25 is 37 and 2.5 is 3); held to 0..10⁹, so
|
|
432
|
+
no published figure can overflow a frame or a float."""
|
|
433
|
+
return half_up(round(min(max(value, 0.0), 1e9) * 10), 10)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def percent(fraction):
|
|
437
|
+
"""A finite 0–1 fraction as a whole percent (`53`), half up, never below 0."""
|
|
438
|
+
return whole(fraction * 100) if fraction < 1e7 else whole(1e9)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def short_name(value, cells):
|
|
442
|
+
"""A name from status.json as a row prints it: printable (control and format characters dropped — it is
|
|
443
|
+
published data, never an escape), at most `cells` cells; '' when it is no string."""
|
|
444
|
+
return clip(OSC_UNSAFE.sub('', value), cells)[0] if isinstance(value, str) else ''
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def site_dashboard(path=None):
|
|
448
|
+
"""LANES_FLEET_DASHBOARD_URL from the server's site.conf (read, never executed; at most SITE_CONF_MAX bytes): ''
|
|
449
|
+
when the file or the line is absent, unreadable, or the value is not an https URL of DNS-name characters."""
|
|
450
|
+
try:
|
|
451
|
+
with open(path or SITE_CONF, 'rb') as handle:
|
|
452
|
+
text = handle.read(SITE_CONF_MAX).decode('utf-8', 'replace')
|
|
453
|
+
except OSError:
|
|
454
|
+
return ''
|
|
455
|
+
found = [match.group(1) for match in map(DASHBOARD_LINE.fullmatch, text.splitlines()) if match]
|
|
456
|
+
return found[-1] if found and DASHBOARD_URL.match(found[-1]) else ''
|
|
457
|
+
|
|
458
|
+
|
|
270
459
|
# The six words statusd publishes (alignment contract A3) and the helper word for an `exec` lane. There is no
|
|
271
460
|
# seventh: statusd's zmx observer used to publish `idle`, which no other surface knew (DEF-823). Values are
|
|
272
461
|
# (unicode, ascii, tone) from the one token source — the tone is a role name from tokens.ROLES, never an SGR code:
|
|
@@ -390,6 +579,18 @@ def mtime(path):
|
|
|
390
579
|
return 0
|
|
391
580
|
|
|
392
581
|
|
|
582
|
+
def file_stamp(path):
|
|
583
|
+
"""(st_ino, st_mtime_ns, st_size) of `path`, or 0 when it cannot be stat()ed. For a file replaced by rename
|
|
584
|
+
(users.update -> atomic_write: a new temp file, created while the old one still exists, then renamed over it) every
|
|
585
|
+
write has a new inode, so two writes inside one timestamp tick of the file system still differ — an mtime alone
|
|
586
|
+
reads them as one (DEF-1972)."""
|
|
587
|
+
try:
|
|
588
|
+
info = os.stat(path)
|
|
589
|
+
except OSError:
|
|
590
|
+
return 0
|
|
591
|
+
return info.st_ino, info.st_mtime_ns, info.st_size
|
|
592
|
+
|
|
593
|
+
|
|
393
594
|
def cell_width(text):
|
|
394
595
|
if text.isascii(): # every ASCII character is one cell here (none combines, none is wide): the common case
|
|
395
596
|
return len(text)
|
|
@@ -455,16 +656,17 @@ BOX_ROWS = 1 # the upload strip (spec §4.6(d)); it was a four-row box
|
|
|
455
656
|
# config.kdl's shared block; lane chords such as Ctrl-Alt-m are no sidebar keys and stay off). 18 lines below the
|
|
456
657
|
# dynamic version row (cockpit v3 V2, LNS-84: ` lanes <current> · view <tree>`, key_sheet) — exactly the 19-row body
|
|
457
658
|
# of a 24-row pane with the strip row; R5's `p h` / `H o` (LNS-78) took the two rows S10 left, and V2's row the line
|
|
458
|
-
# `u z` now share.
|
|
659
|
+
# `u z` now share. Plan W10 W7's `d` (the cursor lane's docs link) joins the strip's own keys, `+ U d`, for the same reason,
|
|
660
|
+
# and W11's `i` (fold the fleet block) the reload and the sheet, `r i ?`.
|
|
459
661
|
# test_sidebar.DocumentedKeys holds the sheet, the table and handle() to the same keys.
|
|
460
662
|
KEY_SHEET = (('↑ ↓ j k', 'Up Dn j k', 'move'), ('⏎ → Space', 'Enter Spc', 'open the cursor lane'),
|
|
461
663
|
('J K', 'J K', 'open next / prev lane'), ('Tab S-Tab', 'Tab S-Tab', 'next / prev needing you'),
|
|
462
664
|
('2 3', '2 3', 'show in viewport 2 / 3'), ('/', '/', 'find a lane'),
|
|
463
665
|
('Esc', 'Esc', 'cancel, back to lane'), ('g G', 'g G', 'first / last'),
|
|
464
|
-
('+ U', '+ U', '
|
|
666
|
+
('+ U d', '+ U d', 'shell / upload / docs'), ('a', 'a', 'add to a workspace'),
|
|
465
667
|
('x', 'x', 'close (y, then Enter)'), ('u z', 'u z', 'unregister / undo 10 s'),
|
|
466
668
|
('R', 'R', 'rename'), ('P', 'P', 'register a session'), ('p h', 'p h', 'pin / hide lane'),
|
|
467
|
-
('H o', 'H o', 'hide tab / hidden lanes'), ('r ?', 'r ?', 'reload /
|
|
669
|
+
('H o', 'H o', 'hide tab / hidden lanes'), ('r i ?', 'r i ?', 'reload / fleet / keys'),
|
|
468
670
|
('Ctrl-Alt-l', 'Ctrl-Alt-l', 'focus the sidebar'))
|
|
469
671
|
PASTE_START, PASTE_END = '\033[200~', '\033[201~'
|
|
470
672
|
# A paste typed into a footer field (Sidebar.pasted): a run of line breaks / tabs is one space, never an Enter.
|
|
@@ -649,6 +851,18 @@ class Sidebar:
|
|
|
649
851
|
# W9 U6: whether clients/<device>.update-ready existed at the last look (poll_update), when to look next, and until
|
|
650
852
|
# when a rebuild `r` started counts as on its way (monotonic seconds)
|
|
651
853
|
update_ready, update_poll_at, rebuild_until = False, 0.0, 0.0
|
|
854
|
+
# The docs cockpit (plan W10 W7): status.json's `web_origin` ('' = none known, so no link), this workspace's
|
|
855
|
+
# document count (reload), the link of the footer message `d` set ('' = the message is no link; the message
|
|
856
|
+
# setter clears it) and the link the footer row draws this frame (footer_text sets it).
|
|
857
|
+
web_origin, docs_total, _message_link, footer_link = '', 0, '', ''
|
|
858
|
+
# The fleet block (W11; read-only defaults, every write assigns fresh values): status.json's `fleet` and `throttle`
|
|
859
|
+
# (take_fleet), `i`'s fold (the owner's profile look.fleet_collapsed; this process's own on a device nobody owns),
|
|
860
|
+
# the dashboard link (site.conf, fleet_url), the (pulse at, 429 total) samples `429 +N` is measured over, the
|
|
861
|
+
# block's place in the last frame (`block_top` its first 1-based row, 0 = not drawn; `block_rows` how many) and the
|
|
862
|
+
# stale verdicts poll_fleet last saw (fleet, throttle).
|
|
863
|
+
fleet_facts, throttle_facts, fleet_collapsed, fleet_url = {}, {}, False, ''
|
|
864
|
+
surge_samples, block_top, block_rows, fleet_frozen = (), 0, 0, (False, False)
|
|
865
|
+
block_says_money = False # fleet_block: the unfolded Throttle row drew the money and every part after it
|
|
652
866
|
|
|
653
867
|
def __init__(self, device, workspace, slot='main', viewports='1'):
|
|
654
868
|
self.device, self.workspace, self.slot = device, workspace, slot
|
|
@@ -726,6 +940,7 @@ class Sidebar:
|
|
|
726
940
|
self.current_version = (self.tree_version if self.current_path == str(L.REPO)
|
|
727
941
|
else shown_version(self.current_path))
|
|
728
942
|
self.version_at = time.monotonic()
|
|
943
|
+
self.fleet_url = site_dashboard() # W11: LANES_FLEET_DASHBOARD_URL, again with every version recheck
|
|
729
944
|
# A predecessor that swapped itself out left its cursor, scroll, seen marks and filter (cockpit v3 U5).
|
|
730
945
|
self.swappable, self.restore_id = False, None
|
|
731
946
|
self.restored = self.take_state()
|
|
@@ -775,9 +990,13 @@ class Sidebar:
|
|
|
775
990
|
self.status = status.get('lanes', {})
|
|
776
991
|
self.unregistered = (status.get('unregistered') or {}).get(self.workspace, [])
|
|
777
992
|
self.zmx = (status.get('zmx') or {}).get(self.workspace, [])
|
|
993
|
+
origin = status.get('web_origin')
|
|
994
|
+
self.web_origin = origin if isinstance(origin, str) and WEB_ORIGIN.match(origin) else ''
|
|
995
|
+
self.take_fleet(status if isinstance(status, dict) else {}) # W11: the fleet block's facts
|
|
778
996
|
self.keep_moved_row()
|
|
779
997
|
if stamp[0] != self.stamp[0] or stamp[1] != self.stamp[1] or force:
|
|
780
998
|
self.fleet = self.fleet_counts()
|
|
999
|
+
self.docs_total = self.workspace_docs()
|
|
781
1000
|
look = stamp[3] != self.stamp[3] or force
|
|
782
1001
|
profile = stamp[6] != self.stamp[6] or force
|
|
783
1002
|
if look:
|
|
@@ -839,6 +1058,9 @@ class Sidebar:
|
|
|
839
1058
|
if now - self.version_at < VERSION_RECHECK_S:
|
|
840
1059
|
return False
|
|
841
1060
|
self.version_at = now
|
|
1061
|
+
url = site_dashboard() # W11: the dashboard link follows site.conf within 30 s
|
|
1062
|
+
if url != self.fleet_url:
|
|
1063
|
+
self.fleet_url, self.dirty = url, True
|
|
842
1064
|
if self.current_path == str(L.REPO):
|
|
843
1065
|
return False
|
|
844
1066
|
version = shown_version(self.current_path)
|
|
@@ -958,8 +1180,9 @@ class Sidebar:
|
|
|
958
1180
|
return None, None
|
|
959
1181
|
|
|
960
1182
|
def profile_stamp(self):
|
|
961
|
-
"""reload()'s seventh stamp field: the profile's mtime
|
|
962
|
-
|
|
1183
|
+
"""reload()'s seventh stamp field: the profile's (inode, mtime, size) (`file_stamp`: two writes in one mtime
|
|
1184
|
+
tick are both seen, DEF-1972) — 0, with no stat at all, on a device nobody owns."""
|
|
1185
|
+
return file_stamp(self.profile_path) if self.profile_path else 0
|
|
963
1186
|
|
|
964
1187
|
def read_profile(self):
|
|
965
1188
|
"""The person's profile as users.read normalizes it (None: no owner, no file yet, or a file this release does
|
|
@@ -967,6 +1190,8 @@ class Sidebar:
|
|
|
967
1190
|
profile = users.read(self.login, os.path.dirname(self.profile_path)) if self.profile_path else None
|
|
968
1191
|
self.profile_look = dict(profile['look']) if profile else {}
|
|
969
1192
|
self.take_arrangement(profile)
|
|
1193
|
+
if self.profile_path: # W11 `i`: the owner's fold; a device nobody owns keeps its own
|
|
1194
|
+
self.fleet_collapsed = users.look_flag(profile, 'fleet_collapsed')
|
|
970
1195
|
return profile
|
|
971
1196
|
|
|
972
1197
|
def take_arrangement(self, profile):
|
|
@@ -1418,6 +1643,312 @@ class Sidebar:
|
|
|
1418
1643
|
lead_painted = f'{tone["ink-dim"]}{lead}{tone["reset"]}' if stale else lead
|
|
1419
1644
|
return lead_painted + sep_painted.join(painted), lead + sep_plain.join(plain)
|
|
1420
1645
|
|
|
1646
|
+
# ---------------------------------------------------------- the fleet block (W11, spec 2026-10-11 §3)
|
|
1647
|
+
def take_fleet(self, status):
|
|
1648
|
+
"""reload()'s status.json read: its `fleet` and `throttle` objects (fleet_doc) and, once per new pulse sample
|
|
1649
|
+
(`throttle.at` moved), the pools' summed `upstream_429_total` for `429 +N` (surge). Fresh values only."""
|
|
1650
|
+
self.fleet_facts = fleet_doc(status.get('fleet'))
|
|
1651
|
+
self.throttle_facts = fleet_doc(status.get('throttle'))
|
|
1652
|
+
at = finite(self.throttle_facts.get('at'))
|
|
1653
|
+
pools = self.throttle_facts.get('pools')
|
|
1654
|
+
totals = [finite(pool.get('upstream_429_total')) for pool in pools.values() if isinstance(pool, dict)] \
|
|
1655
|
+
if isinstance(pools, dict) else []
|
|
1656
|
+
totals = [total for total in totals if total is not None]
|
|
1657
|
+
if at is None or not totals:
|
|
1658
|
+
return
|
|
1659
|
+
total, samples = sum(totals), self.surge_samples
|
|
1660
|
+
if samples and at <= samples[-1][0]:
|
|
1661
|
+
return # the same pulse sample (statusd republished), or an older one
|
|
1662
|
+
if samples and total < samples[-1][1]:
|
|
1663
|
+
samples = () # the counter went down: a restarted daemon counts again
|
|
1664
|
+
samples += ((at, total),)
|
|
1665
|
+
start = at - SURGE_S
|
|
1666
|
+
keep = max((i for i, (when, _total) in enumerate(samples) if when <= start), default=0)
|
|
1667
|
+
self.surge_samples = samples[keep:][-SURGE_SAMPLES:]
|
|
1668
|
+
|
|
1669
|
+
def surge(self):
|
|
1670
|
+
"""`429 +N`'s N: the summed upstream_429_total's growth from the newest sample at or before SURGE_S seconds
|
|
1671
|
+
before the latest one (the oldest this sidebar has, while it has seen less than that) to the latest."""
|
|
1672
|
+
samples = self.surge_samples
|
|
1673
|
+
return samples[-1][1] - samples[0][1] if len(samples) > 1 else 0
|
|
1674
|
+
|
|
1675
|
+
def fleet_aged(self, now):
|
|
1676
|
+
"""(fleet stale, throttle stale): the publisher's flag, or the object's own `at` past FLEET_STALE_S /
|
|
1677
|
+
PULSE_STALE_S by our clock (aged)."""
|
|
1678
|
+
return aged(self.fleet_facts, now, FLEET_STALE_S), aged(self.throttle_facts, now, PULSE_STALE_S)
|
|
1679
|
+
|
|
1680
|
+
def poll_fleet(self):
|
|
1681
|
+
"""Repaint when either object crosses into (or out of) stale. Polled every tick like poll_economy(): a statusd
|
|
1682
|
+
that stopped never changes status.json's mtime again, reload()'s only trigger. In-memory only: no I/O."""
|
|
1683
|
+
frozen = self.fleet_aged(time.time())
|
|
1684
|
+
if frozen != self.fleet_frozen:
|
|
1685
|
+
self.fleet_frozen, self.dirty = frozen, True
|
|
1686
|
+
|
|
1687
|
+
def fleet_money(self):
|
|
1688
|
+
"""(text, role) of ONE pool's overflow money — `$8.5k/16k 53%` — or None (no token: every overflow figure is
|
|
1689
|
+
null). The pools' daily budgets are separate, non-fungible money, so the figure is never a sum: it is the pool
|
|
1690
|
+
nearest its cap — one at or over its alert level first, then the highest fraction spent, Anthropic on a tie (a
|
|
1691
|
+
pool with spend but no budget ranks last and shows `$120` alone). The attention tone when that pool's fraction
|
|
1692
|
+
reached its alert level (the ranking puts any such pool first)."""
|
|
1693
|
+
overflow = self.throttle_facts.get('overflow')
|
|
1694
|
+
overflow = overflow if isinstance(overflow, dict) else {}
|
|
1695
|
+
known = [] # (alarm, fraction or -1, spend, budget) in MONEY_POOLS order
|
|
1696
|
+
for key in MONEY_POOLS:
|
|
1697
|
+
pool = overflow.get(key) if isinstance(overflow.get(key), dict) else {}
|
|
1698
|
+
spent, cap = finite(pool.get('spend_today_usd')), finite(pool.get('budget_usd'))
|
|
1699
|
+
if spent is None:
|
|
1700
|
+
continue
|
|
1701
|
+
fraction, alert = finite(pool.get('fraction')), finite(pool.get('alert_level_pct'))
|
|
1702
|
+
if fraction is None and cap:
|
|
1703
|
+
fraction = spent / cap
|
|
1704
|
+
alarm = fraction is not None and alert is not None and fraction * 100 >= alert
|
|
1705
|
+
known.append((alarm, fraction if fraction is not None and cap else -1.0, spent, cap))
|
|
1706
|
+
if not known:
|
|
1707
|
+
return None
|
|
1708
|
+
alarm, fraction, spend, budget = max(known, key=lambda pool: pool[:2]) # max keeps the first on a tie
|
|
1709
|
+
text = '$' + compact_usd(spend)
|
|
1710
|
+
if budget and fraction >= 0:
|
|
1711
|
+
text += f'/{compact_usd(budget)} {percent(fraction)}%'
|
|
1712
|
+
return text, 'tone-attention' if alarm else 'ink-dim'
|
|
1713
|
+
|
|
1714
|
+
def throttle_shows_money(self):
|
|
1715
|
+
"""True when the block's Throttle row carries the overflow money (throttle_spec's tokened head): a pulse that
|
|
1716
|
+
is not `up: false` and whose token gave fleet_money() a figure. An untokened or down pulse shows `up …` /
|
|
1717
|
+
`down`, never the money — the strip row's economy line must not yield to it (spec 2026-10-11 §3)."""
|
|
1718
|
+
return bool(self.throttle_facts) and self.throttle_facts.get('up') is not False \
|
|
1719
|
+
and self.fleet_money() is not None
|
|
1720
|
+
|
|
1721
|
+
def throttle_spec(self, now):
|
|
1722
|
+
"""The Throttle row: (head after the lead mark, parts, worst role). Tokened: `$8.5k/16k 53% · 429 +4 · spill
|
|
1723
|
+
76%`; else `up · spill 76% · 429 +4`; `down · <error>` in the urgent tone when the daemon did not answer; `no
|
|
1724
|
+
pulse` before statusd has published one. Spill is the most-spilling pool's, shown above 0 %."""
|
|
1725
|
+
facts = self.throttle_facts
|
|
1726
|
+
if not facts:
|
|
1727
|
+
return 'throttle', [[('no pulse', 'ink-dim')]], 'ink-dim'
|
|
1728
|
+
if facts.get('up') is False:
|
|
1729
|
+
parts = [[('down', 'tone-urgent')]]
|
|
1730
|
+
error = short_name(facts.get('error'), 24)
|
|
1731
|
+
if error:
|
|
1732
|
+
parts.append([(error, 'ink-dim')])
|
|
1733
|
+
return 'throttle', parts, 'tone-urgent'
|
|
1734
|
+
pools = facts.get('pools') if isinstance(facts.get('pools'), dict) else {}
|
|
1735
|
+
spills = [finite(pool.get('spill_fraction')) for pool in pools.values() if isinstance(pool, dict)]
|
|
1736
|
+
spill = max((value for value in spills if value is not None), default=None)
|
|
1737
|
+
spill = [[(f'spill {percent(spill)}%', 'ink-dim')]] if spill is not None and percent(spill) > 0 else []
|
|
1738
|
+
surge = self.surge()
|
|
1739
|
+
surge = [[(f'429 +{int(surge)}', 'ink-dim')]] if surge > 0 else []
|
|
1740
|
+
money = self.fleet_money()
|
|
1741
|
+
if money:
|
|
1742
|
+
return 'throttle', [[money]] + surge + spill, money[1]
|
|
1743
|
+
return 'throttle', [[('up' if facts.get('up') is True else '?', 'ink-dim')]] + spill + surge, 'ink-dim'
|
|
1744
|
+
|
|
1745
|
+
def provider_spec(self, key, word, now):
|
|
1746
|
+
"""A provider's row: (head, parts, worst). The availability bar — usable/configured over BAR_CELLS cells, usable
|
|
1747
|
+
▰ cooling ▱ benched ▪ dead · each in its tone (bar_segments, bar_cells) — and, after one blank, `12/19` (the
|
|
1748
|
+
attention tone while none of a configured pool is usable; a pool with nothing configured is no alarm), then the
|
|
1749
|
+
tokened pulse's worst `wk N%` / `5h N%` utilization and the soonest reset. `no data` without the pool."""
|
|
1750
|
+
pools = self.throttle_facts.get('pools')
|
|
1751
|
+
pool = pools.get(key) if isinstance(pools, dict) else None
|
|
1752
|
+
segments = bar_segments(pool)
|
|
1753
|
+
if segments is None:
|
|
1754
|
+
return word, [[('no data', 'ink-dim')]], 'ink-dim'
|
|
1755
|
+
marks = self.glyphs['marks']
|
|
1756
|
+
bar = [(marks[name] * cells, MARKS[name][2]) for name, cells in zip(BAR_MARKS, bar_cells(segments)) if cells]
|
|
1757
|
+
usable, configured = segments[0], sum(segments)
|
|
1758
|
+
worst = 'tone-attention' if configured and not usable else 'ink-dim'
|
|
1759
|
+
parts = [bar + [(' ', None), (f'{usable}/{configured}', worst)]] # `▰▰▰▱▱ 12/19`: one part, one blank
|
|
1760
|
+
for field, prefix in (('worst_7d', 'wk'), ('worst_5h', '5h')):
|
|
1761
|
+
value = finite(pool.get(field))
|
|
1762
|
+
if value is not None:
|
|
1763
|
+
parts.append([(f'{prefix} {percent(value)}%', 'ink-dim')])
|
|
1764
|
+
reset = finite(pool.get('soonest_reset'))
|
|
1765
|
+
if reset is not None and reset > now:
|
|
1766
|
+
parts.append([(f'reset {age3(reset - now).strip()}', 'ink-dim')])
|
|
1767
|
+
return word, parts, worst
|
|
1768
|
+
|
|
1769
|
+
def worse_provider(self):
|
|
1770
|
+
"""(pool key, word) of the provider the three-row block shows, over every PROVIDERS pool: the one with no usable
|
|
1771
|
+
account, else the lower usable share; Anthropic, then OpenAI, on a tie. A pool with nothing configured ranks
|
|
1772
|
+
after every configured pool — it is no alarm (provider_spec), and a daemon without Kimi accounts reports its
|
|
1773
|
+
kimi pool so (DEF-2002) — and a pool the pulse does not report ranks last."""
|
|
1774
|
+
pools = self.throttle_facts.get('pools')
|
|
1775
|
+
pools = pools if isinstance(pools, dict) else {}
|
|
1776
|
+
|
|
1777
|
+
def share(provider):
|
|
1778
|
+
segments = bar_segments(pools.get(provider[0]))
|
|
1779
|
+
if segments is None:
|
|
1780
|
+
return 3.0
|
|
1781
|
+
configured = sum(segments)
|
|
1782
|
+
return segments[0] / configured if configured else 2.0
|
|
1783
|
+
return min(PROVIDERS, key=share)
|
|
1784
|
+
|
|
1785
|
+
def host_spec(self):
|
|
1786
|
+
"""The host row: (head, parts, worst) — `productbox cpu 42% · mem 61% · load 37 · psi 3%`; cpu at
|
|
1787
|
+
CPU_ATTENTION (one sample), memory at MEM_ATTENTION and any `memory.full` stall in the attention tone. psi is
|
|
1788
|
+
the highest avg10 statusd publishes."""
|
|
1789
|
+
facts = self.fleet_facts
|
|
1790
|
+
name = short_name(facts.get('host.name'), 12) or 'host'
|
|
1791
|
+
if not facts:
|
|
1792
|
+
return name, [[('no data', 'ink-dim')]], 'ink-dim'
|
|
1793
|
+
parts, worst = [], 'ink-dim'
|
|
1794
|
+
pressure = facts.get('system.pressure') if isinstance(facts.get('system.pressure'), dict) else {}
|
|
1795
|
+
stalls = [value for value in map(finite, pressure.values()) if value is not None]
|
|
1796
|
+
full = finite(pressure.get('memory.full.avg10'))
|
|
1797
|
+
load = finite(facts.get('system.cpu.load_average.1m'))
|
|
1798
|
+
for text, value, alarm in (
|
|
1799
|
+
('cpu', finite(facts.get('system.cpu.utilization')), lambda v: v >= CPU_ATTENTION),
|
|
1800
|
+
('mem', finite(facts.get('system.memory.utilization')), lambda v: v >= MEM_ATTENTION)):
|
|
1801
|
+
if value is not None:
|
|
1802
|
+
role = 'tone-attention' if alarm(value) else 'ink-dim'
|
|
1803
|
+
parts.append([(f'{text} {percent(value)}%', role)])
|
|
1804
|
+
worst = max(worst, role, key=lambda r: SEVERITY.get(r, 0))
|
|
1805
|
+
if load is not None:
|
|
1806
|
+
parts.append([(f'load {whole(load)}', 'ink-dim')])
|
|
1807
|
+
if stalls:
|
|
1808
|
+
role = 'tone-attention' if full is not None and full > 0 else 'ink-dim'
|
|
1809
|
+
parts.append([(f'psi {whole(max(stalls))}%', role)])
|
|
1810
|
+
worst = max(worst, role, key=lambda r: SEVERITY.get(r, 0))
|
|
1811
|
+
return name, parts, worst
|
|
1812
|
+
|
|
1813
|
+
def containers_spec(self):
|
|
1814
|
+
"""The containers row: (head, parts, worst) — `bw-tools 14G · worlds 8.4G · sakio 3.7G`, statusd's top three
|
|
1815
|
+
by memory in its order."""
|
|
1816
|
+
found = self.fleet_facts.get('containers')
|
|
1817
|
+
parts = []
|
|
1818
|
+
for container in found[:3] if isinstance(found, list) else ():
|
|
1819
|
+
if not isinstance(container, dict):
|
|
1820
|
+
continue
|
|
1821
|
+
name = short_name(container.get('container.name'), 14)
|
|
1822
|
+
used = finite(container.get('container.memory.usage.total'))
|
|
1823
|
+
if name and used is not None:
|
|
1824
|
+
parts.append([(f'{name} {compact_gib(used)}', 'ink-dim')])
|
|
1825
|
+
return '', parts or [[('no containers', 'ink-dim')]], 'ink-dim'
|
|
1826
|
+
|
|
1827
|
+
def paint(self, segments):
|
|
1828
|
+
"""[(text, role or None)] painted: one SGR and one reset per run of equal roles (a 16-colour ink-dim may be SGR 2,
|
|
1829
|
+
which only a reset ends), a None role written bare."""
|
|
1830
|
+
tone, out, run, role_of_run = self.tone, [], [], None
|
|
1831
|
+
for text, role in segments + [('', '\0')]:
|
|
1832
|
+
if role != role_of_run and run:
|
|
1833
|
+
joined = ''.join(run)
|
|
1834
|
+
out.append(joined if role_of_run is None else f'{tone[role_of_run]}{joined}{tone["reset"]}')
|
|
1835
|
+
run = []
|
|
1836
|
+
role_of_run = role
|
|
1837
|
+
run.append(text)
|
|
1838
|
+
return ''.join(out)
|
|
1839
|
+
|
|
1840
|
+
def fleet_row(self, mark, spec, width, stale, link=''):
|
|
1841
|
+
"""One block row from a spec (head, parts, worst): ` <mark> <head> ` then the parts joined by the `sep` mark,
|
|
1842
|
+
whole parts dropped from the end to fit `width` cells (economy_strip's rule). The lead mark takes the row's worst
|
|
1843
|
+
tone — of every part, a dropped one included, so a 36-column row still says something needs a look. A Throttle
|
|
1844
|
+
or provider head is a LABEL_CELLS field; the host's name is its own width. Stale: everything ink-dim. `link`:
|
|
1845
|
+
the whole row but its first blank one OSC 8 hyperlink (bytes, no cell)."""
|
|
1846
|
+
return self.fleet_layout(mark, spec, width, stale, link)[0]
|
|
1847
|
+
|
|
1848
|
+
def fleet_layout(self, mark, spec, width, stale, link=''):
|
|
1849
|
+
"""fleet_row's (painted row, whole): `whole` is True when every part of the spec fit — nothing was dropped
|
|
1850
|
+
from the end. render() keeps the strip row's economy line unless the Throttle row is whole (spec 2026-10-11
|
|
1851
|
+
§3: the strip yields only when the block shows the same facts)."""
|
|
1852
|
+
head, parts, worst = spec
|
|
1853
|
+
marks = self.glyphs['marks']
|
|
1854
|
+
label = head.ljust(LABEL_CELLS) if mark in ('fleet-throttle', 'fleet-provider') else head
|
|
1855
|
+
segments = [(marks[mark], 'ink-dim' if stale else worst if worst in SEVERITY else MARKS[mark][2]),
|
|
1856
|
+
(f' {label} ' if label else ' ', 'ink-dim')]
|
|
1857
|
+
if stale and mark == 'fleet-throttle':
|
|
1858
|
+
segments.append((f'{marks["stale"]} ', 'ink-dim'))
|
|
1859
|
+
used, sep, first = 1 + sum(cell_width(text) for text, _role in segments), f' {marks["sep"]} ', True
|
|
1860
|
+
drawn = 0
|
|
1861
|
+
for part in parts:
|
|
1862
|
+
cost = sum(cell_width(text) for text, _role in part) + (0 if first else cell_width(sep))
|
|
1863
|
+
if used + cost > width:
|
|
1864
|
+
break
|
|
1865
|
+
segments += ([] if first else [(sep, 'ink-dim')]) + part
|
|
1866
|
+
used, first, drawn = used + cost, False, drawn + 1
|
|
1867
|
+
if stale:
|
|
1868
|
+
segments = [(text, 'ink-dim') for text, _role in segments]
|
|
1869
|
+
return ' ' + osc8(link, self.paint(segments)), drawn == len(parts)
|
|
1870
|
+
|
|
1871
|
+
def fleet_rule(self, cols):
|
|
1872
|
+
"""` ── fleet ───…` to cols − 1: the rule ink-faint, the word ink-dim (divider_line's look)."""
|
|
1873
|
+
rule = self.glyphs['marks']['rule']
|
|
1874
|
+
return ' ' + self.paint([(rule * 2 + ' ', 'ink-faint'), ('fleet', 'ink-dim'),
|
|
1875
|
+
(' ' + rule * max(0, cols - 1 - 10), 'ink-faint')])
|
|
1876
|
+
|
|
1877
|
+
def fleet_folded(self, specs, width, stale):
|
|
1878
|
+
"""`i`'s one line: ` fleet ✓ · throttle 53% · cpu 42% · mem 61%` — the verdict is the worst tone of every row
|
|
1879
|
+
(✗ urgent, ⚠ attention, ✓ done), the stale mark while either object is stale; parts dropped from the end."""
|
|
1880
|
+
marks = self.glyphs['marks']
|
|
1881
|
+
worst = max((spec[2] for spec in specs), key=lambda role: SEVERITY.get(role, 0))
|
|
1882
|
+
verdicts = {'tone-urgent': ('fail', 'tone-urgent'), 'tone-attention': ('warning', 'tone-attention')}
|
|
1883
|
+
verdict = ('stale', 'ink-dim') if stale else verdicts.get(worst, ('ok', 'tone-done'))
|
|
1884
|
+
parts = []
|
|
1885
|
+
if self.throttle_facts: # the Throttle row's first part: the money's percent, up, down, ?
|
|
1886
|
+
text, role = specs[0][1][0][0]
|
|
1887
|
+
if text.startswith('$') and text.endswith('%'):
|
|
1888
|
+
text = text.rsplit(' ', 1)[1]
|
|
1889
|
+
parts.append([(f'throttle {text}', role)])
|
|
1890
|
+
parts += [part for part in specs[-1][1] if part[0][0][:4] in ('cpu ', 'mem ')] # the host's cpu and mem
|
|
1891
|
+
segments = [(' fleet ', 'ink-dim'), (marks[verdict[0]], verdict[1])]
|
|
1892
|
+
used, sep = cell_width(' fleet ' + marks[verdict[0]]), f' {marks["sep"]} '
|
|
1893
|
+
for part in parts:
|
|
1894
|
+
cost = cell_width(sep) + sum(cell_width(text) for text, _role in part)
|
|
1895
|
+
if used + cost > width:
|
|
1896
|
+
break
|
|
1897
|
+
segments += [(sep, 'ink-dim')] + part
|
|
1898
|
+
used += cost
|
|
1899
|
+
if stale:
|
|
1900
|
+
segments = [(text, 'ink-dim') for text, _role in segments]
|
|
1901
|
+
return self.paint(segments)
|
|
1902
|
+
|
|
1903
|
+
def fleet_block(self, cols, rows, now):
|
|
1904
|
+
"""The block's painted rows (render, render_mobile), [] when there is none: neither object published (a statusd
|
|
1905
|
+
before W11), a pane under FLEET_SHORT rows, or the key sheet up (it takes the rows back until the next key, so
|
|
1906
|
+
the whole sheet still fits a 24-row pane). Folded (`i`): one line. FLEET_FULL rows or more: the rule, Throttle,
|
|
1907
|
+
Anthropic, OpenAI, Moonshot (Kimi), the host, its containers; fewer: Throttle, the worse provider, the host.
|
|
1908
|
+
Each row at most cols − 1 cells (column 36 never written)."""
|
|
1909
|
+
self.block_says_money = False
|
|
1910
|
+
if self.showing_keys or rows < FLEET_SHORT or not (self.fleet_facts or self.throttle_facts):
|
|
1911
|
+
return []
|
|
1912
|
+
stale, width = any(self.fleet_aged(now)), max(1, cols - 1)
|
|
1913
|
+
throttle, host = self.throttle_spec(now), self.host_spec()
|
|
1914
|
+
link = self.fleet_url
|
|
1915
|
+
if self.fleet_collapsed:
|
|
1916
|
+
return [self.fleet_folded([throttle] + [self.provider_spec(k, w, now) for k, w in PROVIDERS] + [host],
|
|
1917
|
+
width, stale)]
|
|
1918
|
+
# the strip row's economy line yields only to a Throttle row that drew the money AND every part after it (429,
|
|
1919
|
+
# spill): a 36-column row that dropped `spill 76%` does not show the strip's facts, so the strip keeps them
|
|
1920
|
+
throttle_row, whole = self.fleet_layout('fleet-throttle', throttle, width, stale)
|
|
1921
|
+
self.block_says_money = whole and self.throttle_shows_money()
|
|
1922
|
+
if rows < FLEET_FULL:
|
|
1923
|
+
key, word = self.worse_provider()
|
|
1924
|
+
return [throttle_row,
|
|
1925
|
+
self.fleet_row('fleet-provider', self.provider_spec(key, word, now), width, stale),
|
|
1926
|
+
self.fleet_row('fleet-host', host, width, stale, link)]
|
|
1927
|
+
return [self.fleet_rule(cols), throttle_row] + [
|
|
1928
|
+
self.fleet_row('fleet-provider', self.provider_spec(key, word, now), width, stale) for key, word in PROVIDERS
|
|
1929
|
+
] + [self.fleet_row('fleet-host', host, width, stale, link),
|
|
1930
|
+
self.fleet_row('fleet-host', self.containers_spec(), width, stale)]
|
|
1931
|
+
|
|
1932
|
+
def toggle_fleet(self):
|
|
1933
|
+
"""`i`: fold the fleet block to its one line, or unfold it. On a device with an owner the fold is theirs
|
|
1934
|
+
(users.set_look_flag: `look.fleet_collapsed`, through the profile's lock for at most PROFILE_LOCK_S) and every
|
|
1935
|
+
device of theirs follows on its next tick; a write the lock refused changes nothing and says so, keeping the
|
|
1936
|
+
keyboard. On a device nobody owns it is this sidebar's own. Then the keyboard goes back to the lane, as `o`."""
|
|
1937
|
+
self.begin_action()
|
|
1938
|
+
self.dirty = True
|
|
1939
|
+
folded, g = not self.fleet_collapsed, self.glyphs['marks']
|
|
1940
|
+
if self.profile_path and not users.set_look_flag(self.login, self.device, 'fleet_collapsed', folded,
|
|
1941
|
+
directory=os.path.dirname(self.profile_path),
|
|
1942
|
+
timeout=PROFILE_LOCK_S):
|
|
1943
|
+
self.message = f'profile busy or unreadable {g["sep"]} try again'
|
|
1944
|
+
return
|
|
1945
|
+
self.fleet_collapsed = folded
|
|
1946
|
+
if not (self.fleet_facts or self.throttle_facts):
|
|
1947
|
+
self.message = f'fleet {"folded" if folded else "unfolded"} {g["sep"]} no fleet facts yet'
|
|
1948
|
+
else:
|
|
1949
|
+
self.message = f'fleet {"folded" if folded else "unfolded"} {g["sep"]} i {"unfolds" if folded else "folds"}'
|
|
1950
|
+
self.release_focus()
|
|
1951
|
+
|
|
1421
1952
|
def attention(self, entry):
|
|
1422
1953
|
"""The attention section key of a registry entry, or None (the remainder)."""
|
|
1423
1954
|
return self.agent_rows([entry])[0][3]
|
|
@@ -1447,6 +1978,22 @@ class Sidebar:
|
|
|
1447
1978
|
lane_id = entry.get('id')
|
|
1448
1979
|
return bool(lane_id) and not entry.get('divider') and any(e.get('id') == lane_id for e in self.entries)
|
|
1449
1980
|
|
|
1981
|
+
def workspace_docs(self):
|
|
1982
|
+
"""This workspace's documents (plan W10 W7): the `docs.n` of every lane it lists — registry lanes (hidden ones
|
|
1983
|
+
too: their documents exist), zmx lanes and the sessions under Unregistered — each id once. reload() keeps it
|
|
1984
|
+
(`docs_total`) for the upload strip; counts are every login's (DM-LANES-DOCS-COUNTS), the page shows a login
|
|
1985
|
+
only what it may see."""
|
|
1986
|
+
ids = {entry.get('id') for rows in (self.entries, self.zmx, self.unregistered) for entry in rows or ()
|
|
1987
|
+
if isinstance(entry, dict)}
|
|
1988
|
+
return sum(docs_count(self.status.get(lane_id)) for lane_id in ids if lane_id)
|
|
1989
|
+
|
|
1990
|
+
def lane_docs_link(self, lane_id):
|
|
1991
|
+
"""The OSC 8 target of a lane's name on the cursor row: its documents on the docs page, '' when the lane has
|
|
1992
|
+
none or no origin is known (plan W10 W7)."""
|
|
1993
|
+
if not self.web_origin or not docs_count(self.status.get(lane_id)):
|
|
1994
|
+
return ''
|
|
1995
|
+
return docs_url(self.web_origin, self.workspace, lane_id)
|
|
1996
|
+
|
|
1450
1997
|
def header_tally(self):
|
|
1451
1998
|
"""[(agent status, 'glyph+count')] for the header, in `HEADER_COUNTS` order, zeros omitted; a seen `done`
|
|
1452
1999
|
is not counted (it left the Done section)."""
|
|
@@ -1516,8 +2063,11 @@ class Sidebar:
|
|
|
1516
2063
|
viewers = viewer_mark(observed).replace('·', self.glyphs['marks']['viewer'])
|
|
1517
2064
|
closable = bool(entry.get('closable')) and not pending # a row being registered offers neither control
|
|
1518
2065
|
can_register = closable and R.eligible(entry)
|
|
2066
|
+
# Plan W10 W7: the cursor row's name is one OSC 8 link to its lane's documents while it has any.
|
|
2067
|
+
link = self.lane_docs_link(entry['id']) if is_cursor else ''
|
|
1519
2068
|
key = (entry.get('name'), entry.get('color'), entry.get('icon'), closable, can_register, key_state, stale,
|
|
1520
|
-
viewers, status, age, tail, tail_role, is_cursor, is_viewing, mark, mark_role, cols, section_tone, hidden
|
|
2069
|
+
viewers, status, age, tail, tail_role, is_cursor, is_viewing, mark, mark_role, cols, section_tone, hidden,
|
|
2070
|
+
link)
|
|
1521
2071
|
cached = self.row_cache.get(entry['id'])
|
|
1522
2072
|
if cached and cached[0] == key:
|
|
1523
2073
|
self.row_hits += 1
|
|
@@ -1552,7 +2102,7 @@ class Sidebar:
|
|
|
1552
2102
|
fg = (tone['ink-faint'] if hidden else f'\033[38;5;{color}m' if isinstance(color, int) and self.color != 'none'
|
|
1553
2103
|
else tone['ink'] if self.vivid else '')
|
|
1554
2104
|
lead = (tone['bold'] if is_viewing else '') + fg
|
|
1555
|
-
name_cell = f'{lead}{clipped}{reset}' if lead else clipped
|
|
2105
|
+
name_cell = osc8(link, f'{lead}{clipped}{reset}' if lead else clipped)
|
|
1556
2106
|
if viewers:
|
|
1557
2107
|
name_cell += f'{tone["ink-dim"]}{viewers}{reset}'
|
|
1558
2108
|
# The `+` register control (README "Daily use") has no column in spec §4.2: it takes the spacer cell 28
|
|
@@ -1799,7 +2349,7 @@ class Sidebar:
|
|
|
1799
2349
|
def begin_action(self):
|
|
1800
2350
|
"""An action begins (cockpit v3 S6): the last action's message goes, so the footer never shows what an EARLIER
|
|
1801
2351
|
action said beside what this one does. Called first by every verb — open (⏎ Space → J K 2 3, Tab, a click on a
|
|
1802
|
-
row), `x`/`×`, `R`, `P`, `a`/`+`, `/`, `U` (and a click on the strip), `+`, `r`, `?`, `u`, `z`, a sidebar-key
|
|
2352
|
+
row), `x`/`×`, `R`, `P`, `a`/`+`, `/`, `U` (and a click on the strip), `+`, `r`, `?`, `u`, `z`, `i`, a sidebar-key
|
|
1803
2353
|
chord. A cursor move, a typed letter, Esc, the wheel, a report begin nothing: a message outlives them for
|
|
1804
2354
|
MESSAGE_S. An undo offer (`u`, cockpit v3 S7) is not a message: only `z`, the next `u` or UNDO_S end it."""
|
|
1805
2355
|
self.message = ''
|
|
@@ -2493,6 +3043,27 @@ class Sidebar:
|
|
|
2493
3043
|
self.message = f'Shown {name}' if hidden else f'Hid {name} {g["sep"]} o shows it'
|
|
2494
3044
|
self.release_focus()
|
|
2495
3045
|
|
|
3046
|
+
def show_docs_link(self, entry):
|
|
3047
|
+
"""`d` (plan W10 W7, LNS-108): the cursor lane's documents link in the footer — a message like every other
|
|
3048
|
+
(MESSAGE_S, or until the next action): the plain URL `https://<host>:8443/lanes-docs/?ws=<ws>&lane=<lane>`, as
|
|
3049
|
+
much of it as the row holds, the whole footer text one OSC 8 link to all of it (⌘/Ctrl-click, or the
|
|
3050
|
+
terminal's Copy Link, where 36 cells clip the text). Without an origin the path alone, no link. Then the
|
|
3051
|
+
keyboard goes back to the lane, as after `p`; a row with no documents, or no lane, says so and keeps it."""
|
|
3052
|
+
self.begin_action()
|
|
3053
|
+
self.dirty = True
|
|
3054
|
+
g = self.glyphs['marks']
|
|
3055
|
+
lane_id = (entry or {}).get('id')
|
|
3056
|
+
if not lane_id or entry.get('divider') or entry.get('action'):
|
|
3057
|
+
self.message = 'no docs here'
|
|
3058
|
+
return
|
|
3059
|
+
if not docs_count(self.status.get(lane_id)):
|
|
3060
|
+
self.message = f'no docs in {g["quote-open"]}{entry.get("name", lane_id)}{g["quote-close"]}'
|
|
3061
|
+
return
|
|
3062
|
+
url = docs_url(self.web_origin, self.workspace, lane_id)
|
|
3063
|
+
self.message = url
|
|
3064
|
+
self._message_link = url if self.web_origin else ''
|
|
3065
|
+
self.release_focus()
|
|
3066
|
+
|
|
2496
3067
|
def toggle_hidden(self):
|
|
2497
3068
|
"""`o`: show the lanes the owner hid (faint, in their places; `h` there shows one for good) or hide them again.
|
|
2498
3069
|
The footer says how many; then the keyboard goes back to the lane."""
|
|
@@ -2819,14 +3390,15 @@ class Sidebar:
|
|
|
2819
3390
|
|
|
2820
3391
|
@message.setter
|
|
2821
3392
|
def message(self, text):
|
|
2822
|
-
# Every assignment is stamped (cockpit v3 S6): the message lasts MESSAGE_S from when it was SAID.
|
|
2823
|
-
|
|
3393
|
+
# Every assignment is stamped (cockpit v3 S6): the message lasts MESSAGE_S from when it was SAID. A new message
|
|
3394
|
+
# is no link until `d` says it is one (plan W10 W7: show_docs_link sets `_message_link` after it).
|
|
3395
|
+
self._message, self.message_at, self._message_link = text, time.monotonic(), ''
|
|
2824
3396
|
|
|
2825
3397
|
def expire_message(self, now=None):
|
|
2826
3398
|
"""run()'s tick (cockpit v3 S6): a message MESSAGE_S old goes -> True when it went (one repaint). Otherwise only
|
|
2827
3399
|
the next action's begin_action() clears it — never a key, a report, the wheel or a release."""
|
|
2828
3400
|
if self._message and (time.monotonic() if now is None else now) - self.message_at >= MESSAGE_S:
|
|
2829
|
-
self._message = ''
|
|
3401
|
+
self._message, self._message_link = '', ''
|
|
2830
3402
|
return True
|
|
2831
3403
|
return False
|
|
2832
3404
|
|
|
@@ -2843,9 +3415,11 @@ class Sidebar:
|
|
|
2843
3415
|
|
|
2844
3416
|
def strip_line(self, cols):
|
|
2845
3417
|
"""The upload strip (spec §4.6(d)): ONE row under the body, at most `cols − 1` cells — idle, armed, sending,
|
|
2846
|
-
or the result for 8 s. Every glyph and separator is a mark, so the ASCII frame is seven-bit.
|
|
3418
|
+
or the result for 8 s. Every glyph and separator is a mark, so the ASCII frame is seven-bit. Idle while this
|
|
3419
|
+
workspace has documents (plan W10 W7): ` ↑ uploads · ▤ docs N (U)`, the whole text one OSC 8 link to the docs
|
|
3420
|
+
page once statusd knows the origin (the page is where W5's browser uploads land too); with none, today's hint."""
|
|
2847
3421
|
tone, g = self.tone, self.glyphs['marks']
|
|
2848
|
-
sep = f' {g["sep"]} '
|
|
3422
|
+
sep, link = f' {g["sep"]} ', ''
|
|
2849
3423
|
if self.upload:
|
|
2850
3424
|
count = len(self.upload['names'])
|
|
2851
3425
|
text, style = f' {g["upload"]} sending {count} file{"s" if count != 1 else ""}', tone['tone-working']
|
|
@@ -2856,10 +3430,13 @@ class Sidebar:
|
|
|
2856
3430
|
elif self.upload_note and time.time() - self.upload_note_at < 8:
|
|
2857
3431
|
text = ' ' + self.upload_note
|
|
2858
3432
|
style = tone['tone-done'] if self.upload_note.startswith(g['ok']) else tone['tone-urgent']
|
|
3433
|
+
elif self.docs_total:
|
|
3434
|
+
text, style = f' {g["upload"]} uploads{sep}{g["docs"]} docs {self.docs_total} (U)', tone['ink-dim']
|
|
3435
|
+
link = docs_url(self.web_origin) if self.web_origin else ''
|
|
2859
3436
|
else:
|
|
2860
3437
|
text, style = f' {g["upload"]} uploads{sep}click, then drop (U)', tone['ink-dim']
|
|
2861
3438
|
text, _used = clip(text, cols - 1)
|
|
2862
|
-
return f'{style}{text}{tone["reset"]}'
|
|
3439
|
+
return f'{style}{osc8(link, text)}{tone["reset"]}'
|
|
2863
3440
|
|
|
2864
3441
|
def footer_text(self, cols, entries=(), legend=False):
|
|
2865
3442
|
"""The footer row -> (text, style, caret): ONE ladder for the desktop and the phone (cockpit v3 S6; until S6 the
|
|
@@ -2872,9 +3449,11 @@ class Sidebar:
|
|
|
2872
3449
|
(r)` (W9 U6: the installed release lays this view out differently; `r` rebuilds it — the desktop's rung and
|
|
2873
3450
|
the phone's, in the accent) › the focus legend (desktop) › the idle hint. Only the picker's way out, the
|
|
2874
3451
|
legend and the idle hint are a device's own. Every glyph, separator and quote is a mark, so the ASCII footer
|
|
2875
|
-
is seven-bit.
|
|
3452
|
+
is seven-bit. `footer_link` says which link footer_row draws this frame: the message's (`d`, plan W10 W7) while
|
|
3453
|
+
the message is the rung shown, else none."""
|
|
2876
3454
|
tone, g = self.tone, self.glyphs['marks']
|
|
2877
3455
|
sep = f' {g["sep"]} '
|
|
3456
|
+
self.footer_link = ''
|
|
2878
3457
|
if self.prompt:
|
|
2879
3458
|
return self.prompt_text(cols)
|
|
2880
3459
|
if self.registration:
|
|
@@ -2892,6 +3471,7 @@ class Sidebar:
|
|
|
2892
3471
|
text, style = f' {g["warning"]} registry: ' + self.errors[0], tone['tone-urgent']
|
|
2893
3472
|
elif self.message:
|
|
2894
3473
|
text, style = ' ' + self.message, tone['ink-dim']
|
|
3474
|
+
self.footer_link = self._message_link
|
|
2895
3475
|
elif self.undo: # cockpit v3 S7: the `u` it undoes, until `z` or UNDO_S
|
|
2896
3476
|
text, style = self.undo_text(cols), tone['ink-dim']
|
|
2897
3477
|
elif self.update_ready: # W9 U6: the installed release lays this view out differently; `r` rebuilds it
|
|
@@ -2942,11 +3522,12 @@ class Sidebar:
|
|
|
2942
3522
|
|
|
2943
3523
|
def footer_row(self, cols, entries=(), legend=False):
|
|
2944
3524
|
"""footer_text() painted: the words in their style, then the caret in tone-working when a field is open. The
|
|
2945
|
-
caller ends the row (`\\033[K\\033[J`); the bytes are the ones render() wrote before cockpit v3 S6.
|
|
3525
|
+
caller ends the row (`\\033[K\\033[J`); the bytes are the ones render() wrote before cockpit v3 S6. `d`'s
|
|
3526
|
+
message (plan W10 W7) is one OSC 8 link to the whole URL, which a 36-cell row clips."""
|
|
2946
3527
|
text, style, caret = self.footer_text(cols, entries, legend)
|
|
2947
3528
|
reset = self.tone['reset']
|
|
2948
3529
|
painted = f'{reset}{self.tone["tone-working"]}{self.glyphs["marks"]["caret"]}{reset}' if caret else ''
|
|
2949
|
-
return f'{style}{text}{painted}{reset}'
|
|
3530
|
+
return f'{style}{osc8(self.footer_link, text)}{painted}{reset}'
|
|
2950
3531
|
|
|
2951
3532
|
def divider_line(self, entry, cols):
|
|
2952
3533
|
"""`── ◆ Needs you (3) ───` (spec §4.4), shared by the desktop and the phone (§4.10 "section headers as
|
|
@@ -3072,14 +3653,21 @@ class Sidebar:
|
|
|
3072
3653
|
entries = self.follow() # the cursor re-resolved onto its lane, clamped (spec §4.5)
|
|
3073
3654
|
self.expire_theme() # no OSC 11 answer within the grace: dark it is (spec §2.4)
|
|
3074
3655
|
now = time.time()
|
|
3075
|
-
# Frame order (spec §4.6): header, body,
|
|
3076
|
-
# attention to show), rule, footer. `box_top` is the upload
|
|
3077
|
-
#
|
|
3656
|
+
# Frame order (spec §4.6, W11 §4.7): header, body, the fleet block (rows ≥ 18, fleet_block), upload strip
|
|
3657
|
+
# (rows ≥ 14), strip row (rows ≥ 18, economy or fleet attention to show), rule, footer. `box_top` is the upload
|
|
3658
|
+
# strip's 1-based row; a click at or below it arms, a click on the block (`block_top`, `block_rows`) opens
|
|
3659
|
+
# nothing. The strip row is the fleet economy line when economy.json knows a fleet fact (Task 12) and the block
|
|
3660
|
+
# does not show the same facts (block_says_money: the unfolded Throttle row drew the money and every part after
|
|
3661
|
+
# it; an untokened `up …`, a `down`, a row that dropped `spill` or `429` to fit, or the fold's bare percent do
|
|
3662
|
+
# not, and the strip keeps its place), else fleet attention.
|
|
3078
3663
|
self.box = rows >= 14 and not self.registration and not self.attach_only
|
|
3079
|
-
|
|
3664
|
+
block = self.fleet_block(cols, rows, now)
|
|
3665
|
+
economy_line = (self.economy_strip(cols - 2, now)[0] if self.economy and rows >= 18
|
|
3666
|
+
and not (block and self.block_says_money) else '')
|
|
3080
3667
|
self.strip_row = rows >= 18 and (bool(economy_line) or bool(self.fleet))
|
|
3081
|
-
body = max(1, rows - 3 - (BOX_ROWS if self.box else 0) - (1 if self.strip_row else 0))
|
|
3082
|
-
self.
|
|
3668
|
+
body = max(1, rows - 3 - (BOX_ROWS if self.box else 0) - (1 if self.strip_row else 0) - len(block))
|
|
3669
|
+
self.block_top, self.block_rows = (2 + body if block else 0), len(block)
|
|
3670
|
+
self.box_top = 2 + body + len(block)
|
|
3083
3671
|
rows, hidden_below = self.place_body(entries, body)
|
|
3084
3672
|
self.body_map = rows + ([next(i for i in range(self.top, len(entries)) if i not in rows)] if hidden_below else [])
|
|
3085
3673
|
self.more_row = 1 + body if hidden_below else 0 # 1-based screen row of `⋮ N more`, 0 = none
|
|
@@ -3159,6 +3747,7 @@ class Sidebar:
|
|
|
3159
3747
|
# legend rung itself says so, in the accent (keys_here, cockpit v3 S4): ` ▌ keys go to sidebar · Esc → lane` —
|
|
3160
3748
|
# no extra row, every rung above it unchanged.
|
|
3161
3749
|
self.legend_shown = self.keys_here()
|
|
3750
|
+
out.extend(f'{line}\033[K\r\n' for line in block) # W11: pinned above the upload strip
|
|
3162
3751
|
if self.box:
|
|
3163
3752
|
out.append(self.strip_line(cols) + '\033[K\r\n')
|
|
3164
3753
|
if self.strip_row: # spec §4.6(c): the fleet economy line first; without one, fleet attention
|
|
@@ -3177,7 +3766,9 @@ class Sidebar:
|
|
|
3177
3766
|
self.body_map = None # the phone's rows are `top + row` (three terminal rows each); place_body is the desktop's
|
|
3178
3767
|
self.box = False # the Mac file-drop helper is not available in a phone browser
|
|
3179
3768
|
self.entry_top, self.entry_height = 3, 3 if rows >= 8 else 1
|
|
3180
|
-
|
|
3769
|
+
# W11: the fleet block above the footer, as on the desktop (fleet_block: the same rows, the phone's width)
|
|
3770
|
+
block = self.fleet_block(cols, rows, time.time())
|
|
3771
|
+
body = max(1, (rows - 3 - len(block)) // self.entry_height)
|
|
3181
3772
|
self.box_top = self.entry_top + body * self.entry_height
|
|
3182
3773
|
self.top = max(0, min(self.top, self.cursor, len(entries) - body)) # bounded by the list, as in render()
|
|
3183
3774
|
if self.cursor >= self.top + body:
|
|
@@ -3279,8 +3870,10 @@ class Sidebar:
|
|
|
3279
3870
|
detail += sep + 'status delayed'
|
|
3280
3871
|
line(' ' + detail, tone['ink-dim'])
|
|
3281
3872
|
line(' ' + task if task else '', tone['ink-dim'])
|
|
3282
|
-
for _ in range(max(0, rows - (2 + body * self.entry_height + 1))):
|
|
3873
|
+
for _ in range(max(0, rows - (2 + body * self.entry_height + 1 + len(block)))):
|
|
3283
3874
|
line()
|
|
3875
|
+
self.block_top, self.block_rows = (rows - len(block) if block else 0), len(block)
|
|
3876
|
+
out.extend(f'{row}\033[K\r\n' for row in block)
|
|
3284
3877
|
# The footer the desktop draws (footer_text, cockpit v3 S6): every prompt, the filter, the banner, the message;
|
|
3285
3878
|
# only the picker's way out (`Tap Cancel`) and the idle hint (`Swipe to scroll · Tap to open`) are the phone's.
|
|
3286
3879
|
out.append(self.footer_row(cols, entries) + '\033[K\033[J')
|
|
@@ -3411,6 +4004,9 @@ class Sidebar:
|
|
|
3411
4004
|
elif button == 0 and pressed and self.box and y >= self.box_top:
|
|
3412
4005
|
self.begin_action()
|
|
3413
4006
|
self.armed = True # the click also gave this pane the focus: the next drop lands here
|
|
4007
|
+
elif (button == 0 and pressed and self.block_top
|
|
4008
|
+
and self.block_top <= y < self.block_top + self.block_rows):
|
|
4009
|
+
self.release_focus() # W11: the fleet block opens nothing (its link is the terminal's)
|
|
3414
4010
|
elif button == 0 and pressed:
|
|
3415
4011
|
# A click that opens nothing — a section header, `⋮ N more`, the header row, an empty row —
|
|
3416
4012
|
# ends there: the keyboard goes back to the lane (cockpit v3 S3). `×` and `+` open a prompt,
|
|
@@ -3546,6 +4142,10 @@ class Sidebar:
|
|
|
3546
4142
|
self.request_hide_workspace()
|
|
3547
4143
|
elif ch == 'o':
|
|
3548
4144
|
self.toggle_hidden()
|
|
4145
|
+
elif ch == 'i': # W11: fold / unfold the fleet block (the owner's profile)
|
|
4146
|
+
self.toggle_fleet()
|
|
4147
|
+
elif ch == 'd': # plan W10 W7: the cursor lane's docs link in the footer
|
|
4148
|
+
self.show_docs_link(entries[min(self.cursor, len(entries) - 1)] if entries else None)
|
|
3549
4149
|
elif ch == 'P' and entries:
|
|
3550
4150
|
entry = entries[min(self.cursor, len(entries) - 1)]
|
|
3551
4151
|
self.start_promote(entry)
|
|
@@ -3614,6 +4214,7 @@ class Sidebar:
|
|
|
3614
4214
|
self.poll_health()
|
|
3615
4215
|
self.poll_update()
|
|
3616
4216
|
self.poll_economy()
|
|
4217
|
+
self.poll_fleet() # W11: the fleet block's facts turning stale (no I/O)
|
|
3617
4218
|
# An upload in flight no longer forces a frame: poll_upload() dirties on progress and on the end.
|
|
3618
4219
|
if self.armed or (self.upload_note and time.time() - self.upload_note_at < 9):
|
|
3619
4220
|
self.dirty = True
|