@vibelord/lanes 1.3.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.
@@ -7,7 +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)
10
+ d shows the cursor lane's docs link in the footer (plan W10 W7) · i folds / unfolds the fleet block (W11)
11
11
  ? shows every key in the body until the next key · Tab / Shift-Tab open the next / previous lane that needs you
12
12
  The keyboard goes back to the lane after every action (cockpit v3 S3): the sidebar and the lane share one keyboard, so
13
13
  an action that ends — a close, a rename, a register, a cancel, Esc, r, closing the key sheet, a click that opened
@@ -52,6 +52,13 @@ one tail cell by priority: stale `?` > starving ⚠ > downgraded ↓ > mostly pa
52
52
  Docs (plan W10 W7): while the workspace's lanes have published documents the idle strip says ` ↑ uploads · ▤ docs N
53
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
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.
55
62
  Truthful under load: a lane statusd could not observe keeps its last glyph, dimmed, with a `?`; the footer line warns
56
63
  when the host is under pressure or statusd has fallen behind (age of $STATE/health, measured with our own clock).
57
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
@@ -301,6 +308,154 @@ def load_economy(doc):
301
308
  lanes={k: v for k, v in lanes.items() if isinstance(v, dict)} if isinstance(lanes, dict) else {})
302
309
 
303
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
+
304
459
  # The six words statusd publishes (alignment contract A3) and the helper word for an `exec` lane. There is no
305
460
  # seventh: statusd's zmx observer used to publish `idle`, which no other surface knew (DEF-823). Values are
306
461
  # (unicode, ascii, tone) from the one token source — the tone is a role name from tokens.ROLES, never an SGR code:
@@ -424,6 +579,18 @@ def mtime(path):
424
579
  return 0
425
580
 
426
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
+
427
594
  def cell_width(text):
428
595
  if text.isascii(): # every ASCII character is one cell here (none combines, none is wide): the common case
429
596
  return len(text)
@@ -489,7 +656,8 @@ BOX_ROWS = 1 # the upload strip (spec §4.6(d)); it was a four-row box
489
656
  # config.kdl's shared block; lane chords such as Ctrl-Alt-m are no sidebar keys and stay off). 18 lines below the
490
657
  # dynamic version row (cockpit v3 V2, LNS-84: ` lanes <current> · view <tree>`, key_sheet) — exactly the 19-row body
491
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
492
- # `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.
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 ?`.
493
661
  # test_sidebar.DocumentedKeys holds the sheet, the table and handle() to the same keys.
494
662
  KEY_SHEET = (('↑ ↓ j k', 'Up Dn j k', 'move'), ('⏎ → Space', 'Enter Spc', 'open the cursor lane'),
495
663
  ('J K', 'J K', 'open next / prev lane'), ('Tab S-Tab', 'Tab S-Tab', 'next / prev needing you'),
@@ -498,7 +666,7 @@ KEY_SHEET = (('↑ ↓ j k', 'Up Dn j k', 'move'), ('⏎ → Space', 'Enter Spc'
498
666
  ('+ U d', '+ U d', 'shell / upload / docs'), ('a', 'a', 'add to a workspace'),
499
667
  ('x', 'x', 'close (y, then Enter)'), ('u z', 'u z', 'unregister / undo 10 s'),
500
668
  ('R', 'R', 'rename'), ('P', 'P', 'register a session'), ('p h', 'p h', 'pin / hide lane'),
501
- ('H o', 'H o', 'hide tab / hidden lanes'), ('r ?', 'r ?', 'reload / this sheet'),
669
+ ('H o', 'H o', 'hide tab / hidden lanes'), ('r i ?', 'r i ?', 'reload / fleet / keys'),
502
670
  ('Ctrl-Alt-l', 'Ctrl-Alt-l', 'focus the sidebar'))
503
671
  PASTE_START, PASTE_END = '\033[200~', '\033[201~'
504
672
  # A paste typed into a footer field (Sidebar.pasted): a run of line breaks / tabs is one space, never an Enter.
@@ -687,6 +855,14 @@ class Sidebar:
687
855
  # document count (reload), the link of the footer message `d` set ('' = the message is no link; the message
688
856
  # setter clears it) and the link the footer row draws this frame (footer_text sets it).
689
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
690
866
 
691
867
  def __init__(self, device, workspace, slot='main', viewports='1'):
692
868
  self.device, self.workspace, self.slot = device, workspace, slot
@@ -764,6 +940,7 @@ class Sidebar:
764
940
  self.current_version = (self.tree_version if self.current_path == str(L.REPO)
765
941
  else shown_version(self.current_path))
766
942
  self.version_at = time.monotonic()
943
+ self.fleet_url = site_dashboard() # W11: LANES_FLEET_DASHBOARD_URL, again with every version recheck
767
944
  # A predecessor that swapped itself out left its cursor, scroll, seen marks and filter (cockpit v3 U5).
768
945
  self.swappable, self.restore_id = False, None
769
946
  self.restored = self.take_state()
@@ -815,6 +992,7 @@ class Sidebar:
815
992
  self.zmx = (status.get('zmx') or {}).get(self.workspace, [])
816
993
  origin = status.get('web_origin')
817
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
818
996
  self.keep_moved_row()
819
997
  if stamp[0] != self.stamp[0] or stamp[1] != self.stamp[1] or force:
820
998
  self.fleet = self.fleet_counts()
@@ -880,6 +1058,9 @@ class Sidebar:
880
1058
  if now - self.version_at < VERSION_RECHECK_S:
881
1059
  return False
882
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
883
1064
  if self.current_path == str(L.REPO):
884
1065
  return False
885
1066
  version = shown_version(self.current_path)
@@ -999,8 +1180,9 @@ class Sidebar:
999
1180
  return None, None
1000
1181
 
1001
1182
  def profile_stamp(self):
1002
- """reload()'s seventh stamp field: the profile's mtime — 0, with no stat at all, on a device nobody owns."""
1003
- return mtime(self.profile_path) if self.profile_path else 0
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
1004
1186
 
1005
1187
  def read_profile(self):
1006
1188
  """The person's profile as users.read normalizes it (None: no owner, no file yet, or a file this release does
@@ -1008,6 +1190,8 @@ class Sidebar:
1008
1190
  profile = users.read(self.login, os.path.dirname(self.profile_path)) if self.profile_path else None
1009
1191
  self.profile_look = dict(profile['look']) if profile else {}
1010
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')
1011
1195
  return profile
1012
1196
 
1013
1197
  def take_arrangement(self, profile):
@@ -1459,6 +1643,312 @@ class Sidebar:
1459
1643
  lead_painted = f'{tone["ink-dim"]}{lead}{tone["reset"]}' if stale else lead
1460
1644
  return lead_painted + sep_painted.join(painted), lead + sep_plain.join(plain)
1461
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
+
1462
1952
  def attention(self, entry):
1463
1953
  """The attention section key of a registry entry, or None (the remainder)."""
1464
1954
  return self.agent_rows([entry])[0][3]
@@ -1859,7 +2349,7 @@ class Sidebar:
1859
2349
  def begin_action(self):
1860
2350
  """An action begins (cockpit v3 S6): the last action's message goes, so the footer never shows what an EARLIER
1861
2351
  action said beside what this one does. Called first by every verb — open (⏎ Space → J K 2 3, Tab, a click on a
1862
- 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
1863
2353
  chord. A cursor move, a typed letter, Esc, the wheel, a report begin nothing: a message outlives them for
1864
2354
  MESSAGE_S. An undo offer (`u`, cockpit v3 S7) is not a message: only `z`, the next `u` or UNDO_S end it."""
1865
2355
  self.message = ''
@@ -3163,14 +3653,21 @@ class Sidebar:
3163
3653
  entries = self.follow() # the cursor re-resolved onto its lane, clamped (spec §4.5)
3164
3654
  self.expire_theme() # no OSC 11 answer within the grace: dark it is (spec §2.4)
3165
3655
  now = time.time()
3166
- # Frame order (spec §4.6): header, body, upload strip (rows ≥ 14), strip row (rows ≥ 18, economy or fleet
3167
- # attention to show), rule, footer. `box_top` is the upload strip's 1-based row; a click at or below it arms.
3168
- # The strip row is the fleet economy line when economy.json knows a fleet fact (Task 12), else fleet attention.
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.
3169
3663
  self.box = rows >= 14 and not self.registration and not self.attach_only
3170
- economy_line = self.economy_strip(cols - 2, now)[0] if self.economy and rows >= 18 else ''
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 '')
3171
3667
  self.strip_row = rows >= 18 and (bool(economy_line) or bool(self.fleet))
3172
- body = max(1, rows - 3 - (BOX_ROWS if self.box else 0) - (1 if self.strip_row else 0))
3173
- self.box_top = 2 + body
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)
3174
3671
  rows, hidden_below = self.place_body(entries, body)
3175
3672
  self.body_map = rows + ([next(i for i in range(self.top, len(entries)) if i not in rows)] if hidden_below else [])
3176
3673
  self.more_row = 1 + body if hidden_below else 0 # 1-based screen row of `⋮ N more`, 0 = none
@@ -3250,6 +3747,7 @@ class Sidebar:
3250
3747
  # legend rung itself says so, in the accent (keys_here, cockpit v3 S4): ` ▌ keys go to sidebar · Esc → lane` —
3251
3748
  # no extra row, every rung above it unchanged.
3252
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
3253
3751
  if self.box:
3254
3752
  out.append(self.strip_line(cols) + '\033[K\r\n')
3255
3753
  if self.strip_row: # spec §4.6(c): the fleet economy line first; without one, fleet attention
@@ -3268,7 +3766,9 @@ class Sidebar:
3268
3766
  self.body_map = None # the phone's rows are `top + row` (three terminal rows each); place_body is the desktop's
3269
3767
  self.box = False # the Mac file-drop helper is not available in a phone browser
3270
3768
  self.entry_top, self.entry_height = 3, 3 if rows >= 8 else 1
3271
- body = max(1, (rows - 3) // self.entry_height)
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)
3272
3772
  self.box_top = self.entry_top + body * self.entry_height
3273
3773
  self.top = max(0, min(self.top, self.cursor, len(entries) - body)) # bounded by the list, as in render()
3274
3774
  if self.cursor >= self.top + body:
@@ -3370,8 +3870,10 @@ class Sidebar:
3370
3870
  detail += sep + 'status delayed'
3371
3871
  line(' ' + detail, tone['ink-dim'])
3372
3872
  line(' ' + task if task else '', tone['ink-dim'])
3373
- 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)))):
3374
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)
3375
3877
  # The footer the desktop draws (footer_text, cockpit v3 S6): every prompt, the filter, the banner, the message;
3376
3878
  # only the picker's way out (`Tap Cancel`) and the idle hint (`Swipe to scroll · Tap to open`) are the phone's.
3377
3879
  out.append(self.footer_row(cols, entries) + '\033[K\033[J')
@@ -3502,6 +4004,9 @@ class Sidebar:
3502
4004
  elif button == 0 and pressed and self.box and y >= self.box_top:
3503
4005
  self.begin_action()
3504
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)
3505
4010
  elif button == 0 and pressed:
3506
4011
  # A click that opens nothing — a section header, `⋮ N more`, the header row, an empty row —
3507
4012
  # ends there: the keyboard goes back to the lane (cockpit v3 S3). `×` and `+` open a prompt,
@@ -3637,6 +4142,8 @@ class Sidebar:
3637
4142
  self.request_hide_workspace()
3638
4143
  elif ch == 'o':
3639
4144
  self.toggle_hidden()
4145
+ elif ch == 'i': # W11: fold / unfold the fleet block (the owner's profile)
4146
+ self.toggle_fleet()
3640
4147
  elif ch == 'd': # plan W10 W7: the cursor lane's docs link in the footer
3641
4148
  self.show_docs_link(entries[min(self.cursor, len(entries) - 1)] if entries else None)
3642
4149
  elif ch == 'P' and entries:
@@ -3707,6 +4214,7 @@ class Sidebar:
3707
4214
  self.poll_health()
3708
4215
  self.poll_update()
3709
4216
  self.poll_economy()
4217
+ self.poll_fleet() # W11: the fleet block's facts turning stale (no I/O)
3710
4218
  # An upload in flight no longer forces a frame: poll_upload() dirties on progress and on the end.
3711
4219
  if self.armed or (self.upload_note and time.time() - self.upload_note_at < 9):
3712
4220
  self.dirty = True