@vibelord/lanes 1.2.0 → 1.3.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 +4 -3
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +301 -45
- package/payload/lanes/container/lane +42 -1
- package/payload/lanes/container/lane-mcp +65 -6
- package/payload/lanes/container/lane_core.py +178 -0
- package/payload/lanes/docs/ARCHITECTURE.md +192 -20
- package/payload/lanes/docs/DESIGN.md +6 -3
- package/payload/lanes/docs/INSTALL.md +25 -8
- package/payload/lanes/docs/RUNBOOK.md +369 -8
- package/payload/lanes/docs/USERGUIDE.md +201 -25
- 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/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- 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 +106 -13
- package/payload/lanes/libexec/statusd.py +97 -5
- package/payload/lanes/libexec/tokens.py +31 -9
- package/payload/lanes/libexec/update.py +94 -22
- package/payload/lanes/libexec/uploads.py +538 -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 +4 -3
|
@@ -31,10 +31,17 @@ NEEDS_YOU set. The six-word `state` is never read: the agent status is the only
|
|
|
31
31
|
device_active).
|
|
32
32
|
* Payload: `<lane name>: <agent task> — <status>` and, on a second line, the client command that opens the lane
|
|
33
33
|
(`lanes-launch lane <id>`). No client registers a URL scheme for it, so the push carries a tappable action only
|
|
34
|
-
when `action_url` (a template over `{lane}`) is configured.
|
|
34
|
+
when `action_url` (a template over `{lane}`, and `{doc}` for a `published` event — `action_url()`) is configured.
|
|
35
35
|
* A failed send is one journal line (lane id and the failure class — never the topic or the bearer) and is not
|
|
36
36
|
retried; it never stops the service.
|
|
37
37
|
|
|
38
|
+
Published documents (plan W10 W4): the docs service's publish pick-up (`lanes-docsd`, docsd.Pickup), not this
|
|
39
|
+
service, calls `notify_published` once a document an agent published from a lane is stored — the same config, the
|
|
40
|
+
same desk-presence rule (device_active) and the same `send`, at most one push per lane per minute (docsd's
|
|
41
|
+
PUBLISHED_PUSH_S), best effort. Its payload is `{title: 'Published', message: '<lane name> published a document (<kind>,
|
|
42
|
+
<size>)\n/lanes-docs/d/<id>', priority: 2}` and, when `action_url` is set, the tap target with `{doc}` resolved —
|
|
43
|
+
never the document's title, name or content: the topic is the host's, and the document may be for one login only.
|
|
44
|
+
|
|
38
45
|
Enrolment requests (W8 A5, LNS-44): the same service also watches `STATE/enrol-requests/` (lanes-enrold's store;
|
|
39
46
|
read through `enrold.load_requests`, so a request announced is one `lanes enrol list` shows). A request that is
|
|
40
47
|
pending — not approved, denied or past `expires_at` — and has not been announced yet yields ONE push:
|
|
@@ -55,7 +62,9 @@ Config `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` overrides the path), `
|
|
|
55
62
|
`url` (ntfy server, http/https, required), `topic` (`[A-Za-z0-9_-]{1,64}`, required), `token_file` (a file holding
|
|
56
63
|
an ntfy access token, optional — sent as a bearer, never logged; an unreadable one disables sending rather than
|
|
57
64
|
sending unauthenticated), `quiet_when_device_active_s` (default 120; 0 never suppresses), `action_url` (optional
|
|
58
|
-
template, e.g. `https://lanes.example/?lane={lane}
|
|
65
|
+
template, e.g. `https://lanes.example/?lane={lane}`; plan W10 W7 adds `{doc}`, the page `/lanes-docs/d/<id>` of the
|
|
66
|
+
document a `published` push names — empty for a needs-you push; the docs service sends those, W4). Without a valid
|
|
67
|
+
`url` and `topic` the service only tracks
|
|
59
68
|
transitions and sends nothing. The config is re-read when notify.conf OR its `token_file` changes (inode, size,
|
|
60
69
|
mtime or ctime, so a token written, rotated, removed or chmod-ed after notify.conf counts), and on every tick while
|
|
61
70
|
sending is disabled, so a token that becomes readable switches the pushes on within one interval.
|
|
@@ -76,6 +85,7 @@ import urllib.request
|
|
|
76
85
|
from pathlib import Path
|
|
77
86
|
|
|
78
87
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
88
|
+
import docs_store # noqa: E402
|
|
79
89
|
import enrold as E # noqa: E402
|
|
80
90
|
import lanes_lib as L # noqa: E402
|
|
81
91
|
import tokens # noqa: E402
|
|
@@ -95,6 +105,8 @@ PRESENCE = '*.pid' # clients/ files only
|
|
|
95
105
|
SEEN = '.seen.json' # a sidebar's opened-lane record; desk devices only
|
|
96
106
|
# (a .pid of any age, devices/<device>.json not mobile)
|
|
97
107
|
# users/<login>.json written by a desk device counts too
|
|
108
|
+
PUBLISHED_TITLE = 'Published' # a document published from a lane (notify_published)
|
|
109
|
+
PUBLISHED_PRIORITY = 2
|
|
98
110
|
ENROL_DIR = 'enrol-requests' # STATE/<this>/<id>.json: lanes-enrold's requests
|
|
99
111
|
ENROL_SEEN = 'notify.enrol-seen.json' # STATE/<this>: the request ids already announced
|
|
100
112
|
ENROL_TITLE = 'Enrol request'
|
|
@@ -275,6 +287,18 @@ def device_active(clients_dir, window, now, devices_dir=None, users_dir=None, me
|
|
|
275
287
|
return any(within(at, window) for _login, at in marks)
|
|
276
288
|
|
|
277
289
|
|
|
290
|
+
def action_url(template, lane_id, doc_id=None):
|
|
291
|
+
"""notify.conf's `action_url` template made a push's tap target -> the URL, or '' without a template. `{lane}` is
|
|
292
|
+
the lane id; `{doc}` (plan W10 W7) the page of the document a `published` event is about, `/lanes-docs/d/<id>`
|
|
293
|
+
(docs_store.URL; a docs_store id, else nothing), and nothing for an event without one (a needs-you push) — so
|
|
294
|
+
`https://<host>:8443{doc}` opens the document. Both are percent-quoted."""
|
|
295
|
+
if not template:
|
|
296
|
+
return ''
|
|
297
|
+
doc = docs_store.URL.format(doc_id) if isinstance(doc_id, str) and docs_store.ID_RE.match(doc_id) else ''
|
|
298
|
+
return (template.replace('{lane}', urllib.parse.quote(lane_id, safe=''))
|
|
299
|
+
.replace('{doc}', urllib.parse.quote(doc, safe='/')))
|
|
300
|
+
|
|
301
|
+
|
|
278
302
|
def _note(lane_id, status, agent, names, config):
|
|
279
303
|
label = LABELS.get(status, status)
|
|
280
304
|
name = _text(names.get(lane_id) or lane_id)
|
|
@@ -282,14 +306,50 @@ def _note(lane_id, status, agent, names, config):
|
|
|
282
306
|
first = _line(f'{name}: {task} — {label}' if task else f'{name} — {label}')
|
|
283
307
|
note = {'lane': lane_id, 'status': status, 'title': label,
|
|
284
308
|
'message': f'{first}\nlanes-launch lane {lane_id}', 'priority': PRIORITY.get(status, 3)}
|
|
285
|
-
|
|
286
|
-
if
|
|
287
|
-
url = template.replace('{lane}', urllib.parse.quote(lane_id, safe=''))
|
|
309
|
+
url = action_url(config.get('action_url') or '', lane_id)
|
|
310
|
+
if url:
|
|
288
311
|
note['click'] = url
|
|
289
312
|
note['actions'] = [{'action': 'view', 'label': 'Open lane', 'url': url}]
|
|
290
313
|
return note
|
|
291
314
|
|
|
292
315
|
|
|
316
|
+
def _size(size):
|
|
317
|
+
if size < 1024:
|
|
318
|
+
return f'{size} B'
|
|
319
|
+
return f'{size / 1024:.1f} KiB' if size < 1024 * 1024 else f'{size / (1024 * 1024):.1f} MiB'
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def published_note(lane_id, meta, names, config):
|
|
323
|
+
"""The push for a document stored from lane `lane_id` (`meta` its docs_store meta): the lane's name, the kind and
|
|
324
|
+
the size, and the document's page (module docstring) — never its title, name or content."""
|
|
325
|
+
name = _text(names.get(lane_id) or lane_id)
|
|
326
|
+
first = _line(f"{name} published a document ({_text(meta.get('kind'))}, {_size(int(meta.get('bytes') or 0))})")
|
|
327
|
+
note = {'lane': lane_id, 'status': 'published', 'doc': meta['id'], 'title': PUBLISHED_TITLE,
|
|
328
|
+
'message': f"{first}\n{docs_store.URL.format(meta['id'])}", 'priority': PUBLISHED_PRIORITY}
|
|
329
|
+
url = action_url(config.get('action_url') or '', lane_id, meta['id'])
|
|
330
|
+
if url:
|
|
331
|
+
note['click'] = url
|
|
332
|
+
note['actions'] = [{'action': 'view', 'label': 'Open document', 'url': url}]
|
|
333
|
+
return note
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def notify_published(lane_id, meta, names=None, conf_path=None, state=None, now=None, opener=None):
|
|
337
|
+
"""Send the `published` push for a stored document (the docs service's pick-up calls it, W4) -> the note sent, or
|
|
338
|
+
None: sending disabled (no valid notify.conf at `conf_path`, else $LANES_NOTIFY_CONF, else the default), somebody
|
|
339
|
+
at a desk (device_active over `state`, default L.STATE), or the send failed (one journal line). Never raises for a
|
|
340
|
+
failed send; the caller rate-limits."""
|
|
341
|
+
config = read_config(conf_path or os.environ.get('LANES_NOTIFY_CONF') or DEFAULT_CONF)
|
|
342
|
+
if not config['enabled']:
|
|
343
|
+
return None
|
|
344
|
+
state = Path(state) if state is not None else L.STATE
|
|
345
|
+
now = time.time() if now is None else now
|
|
346
|
+
window = int(config.get('quiet_when_device_active_s', DEFAULT_QUIET) or 0)
|
|
347
|
+
if device_active(state / 'clients', window, now, state / 'devices'):
|
|
348
|
+
return None
|
|
349
|
+
note = published_note(lane_id, meta, names or {}, config)
|
|
350
|
+
return note if send(note, config, opener) else None
|
|
351
|
+
|
|
352
|
+
|
|
293
353
|
def notify_changes(prev_status, new_status, config, names=None, now=None, clients_dir=None, devices_dir=None):
|
|
294
354
|
"""The notifications for lanes whose agent status ENTERED needs-you between two status.json documents.
|
|
295
355
|
`prev_status` None = the first read: seed only, nothing is news. Empty when a desk device is present."""
|
|
@@ -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)
|
|
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,9 @@ 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.
|
|
51
55
|
Truthful under load: a lane statusd could not observe keeps its last glyph, dimmed, with a `?`; the footer line warns
|
|
52
56
|
when the host is under pressure or statusd has fallen behind (age of $STATE/health, measured with our own clock).
|
|
53
57
|
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 +70,7 @@ import sys
|
|
|
66
70
|
import termios
|
|
67
71
|
import threading
|
|
68
72
|
import time
|
|
73
|
+
import urllib.parse
|
|
69
74
|
from operator import itemgetter
|
|
70
75
|
from pathlib import Path
|
|
71
76
|
import tty
|
|
@@ -184,6 +189,35 @@ PROFILE_LOCK_S = 0.2
|
|
|
184
189
|
PROFILE_RETRY_S = 5.0
|
|
185
190
|
# 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
191
|
NO_PROFILE = 'no owner: lanes device claim'
|
|
192
|
+
# The docs cockpit (plan W10 W7, LNS-108). The docs page is lanes-docsd behind Caddy's `handle_path /lanes-docs/*`; a
|
|
193
|
+
# lane's documents are its index filtered `?ws=<ws>&lane=<lane>`. statusd publishes each lane's `docs: {n, at}` and the
|
|
194
|
+
# document's `web_origin` (`https://<name>:8443`) in status.json, which this sidebar already reads — no stat, no fork
|
|
195
|
+
# of its own. The links are OSC 8 hyperlinks (bytes, never cells): the upload strip's ` ↑ uploads · ▤ docs N (U)` to
|
|
196
|
+
# the page, the cursor row's name to its lane's documents, and the footer's `d` message to the same. Without an origin
|
|
197
|
+
# there is no link (a terminal needs an absolute URL) and `d` says the path alone. The origin is checked again here: it
|
|
198
|
+
# rides inside an OSC string, so only `https://<DNS host name>:8443` passes.
|
|
199
|
+
DOCS_PATH = '/lanes-docs/'
|
|
200
|
+
WEB_ORIGIN = re.compile(r'https://[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?:8443\Z', re.ASCII)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def docs_count(row):
|
|
204
|
+
"""A status.json lane row's document count (`docs.n`, plan W10 W7): a positive int, else 0."""
|
|
205
|
+
docs = row.get('docs') if isinstance(row, dict) else None
|
|
206
|
+
n = docs.get('n') if isinstance(docs, dict) else None
|
|
207
|
+
return n if isinstance(n, int) and not isinstance(n, bool) and n > 0 else 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def docs_url(origin, ws=None, lane=None):
|
|
211
|
+
"""The docs page on `origin` ('' = the path alone), filtered to a workspace and a lane when given:
|
|
212
|
+
`/lanes-docs/?ws=<ws>&lane=<lane>`, each value percent-quoted (nothing in it can end an OSC string)."""
|
|
213
|
+
query = '&'.join(f'{key}={urllib.parse.quote(value, safe="")}' for key, value in (('ws', ws), ('lane', lane)) if value)
|
|
214
|
+
return f'{origin}{DOCS_PATH}' + (f'?{query}' if query else '')
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def osc8(url, text):
|
|
218
|
+
"""`text` as ONE OSC 8 hyperlink to `url` (ST-terminated, no id); `text` as it is when there is no url. Costs
|
|
219
|
+
len(url) + 14 bytes and no cell: the budget gate's strip and cell walk read through it."""
|
|
220
|
+
return f'\033]8;;{url}\033\\{text}\033]8;;\033\\' if url else text
|
|
187
221
|
|
|
188
222
|
|
|
189
223
|
def shown_version(path):
|
|
@@ -455,13 +489,13 @@ BOX_ROWS = 1 # the upload strip (spec §4.6(d)); it was a four-row box
|
|
|
455
489
|
# config.kdl's shared block; lane chords such as Ctrl-Alt-m are no sidebar keys and stay off). 18 lines below the
|
|
456
490
|
# dynamic version row (cockpit v3 V2, LNS-84: ` lanes <current> · view <tree>`, key_sheet) — exactly the 19-row body
|
|
457
491
|
# 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.
|
|
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.
|
|
459
493
|
# test_sidebar.DocumentedKeys holds the sheet, the table and handle() to the same keys.
|
|
460
494
|
KEY_SHEET = (('↑ ↓ j k', 'Up Dn j k', 'move'), ('⏎ → Space', 'Enter Spc', 'open the cursor lane'),
|
|
461
495
|
('J K', 'J K', 'open next / prev lane'), ('Tab S-Tab', 'Tab S-Tab', 'next / prev needing you'),
|
|
462
496
|
('2 3', '2 3', 'show in viewport 2 / 3'), ('/', '/', 'find a lane'),
|
|
463
497
|
('Esc', 'Esc', 'cancel, back to lane'), ('g G', 'g G', 'first / last'),
|
|
464
|
-
('+ U', '+ U', '
|
|
498
|
+
('+ U d', '+ U d', 'shell / upload / docs'), ('a', 'a', 'add to a workspace'),
|
|
465
499
|
('x', 'x', 'close (y, then Enter)'), ('u z', 'u z', 'unregister / undo 10 s'),
|
|
466
500
|
('R', 'R', 'rename'), ('P', 'P', 'register a session'), ('p h', 'p h', 'pin / hide lane'),
|
|
467
501
|
('H o', 'H o', 'hide tab / hidden lanes'), ('r ?', 'r ?', 'reload / this sheet'),
|
|
@@ -649,6 +683,10 @@ class Sidebar:
|
|
|
649
683
|
# W9 U6: whether clients/<device>.update-ready existed at the last look (poll_update), when to look next, and until
|
|
650
684
|
# when a rebuild `r` started counts as on its way (monotonic seconds)
|
|
651
685
|
update_ready, update_poll_at, rebuild_until = False, 0.0, 0.0
|
|
686
|
+
# The docs cockpit (plan W10 W7): status.json's `web_origin` ('' = none known, so no link), this workspace's
|
|
687
|
+
# document count (reload), the link of the footer message `d` set ('' = the message is no link; the message
|
|
688
|
+
# setter clears it) and the link the footer row draws this frame (footer_text sets it).
|
|
689
|
+
web_origin, docs_total, _message_link, footer_link = '', 0, '', ''
|
|
652
690
|
|
|
653
691
|
def __init__(self, device, workspace, slot='main', viewports='1'):
|
|
654
692
|
self.device, self.workspace, self.slot = device, workspace, slot
|
|
@@ -775,9 +813,12 @@ class Sidebar:
|
|
|
775
813
|
self.status = status.get('lanes', {})
|
|
776
814
|
self.unregistered = (status.get('unregistered') or {}).get(self.workspace, [])
|
|
777
815
|
self.zmx = (status.get('zmx') or {}).get(self.workspace, [])
|
|
816
|
+
origin = status.get('web_origin')
|
|
817
|
+
self.web_origin = origin if isinstance(origin, str) and WEB_ORIGIN.match(origin) else ''
|
|
778
818
|
self.keep_moved_row()
|
|
779
819
|
if stamp[0] != self.stamp[0] or stamp[1] != self.stamp[1] or force:
|
|
780
820
|
self.fleet = self.fleet_counts()
|
|
821
|
+
self.docs_total = self.workspace_docs()
|
|
781
822
|
look = stamp[3] != self.stamp[3] or force
|
|
782
823
|
profile = stamp[6] != self.stamp[6] or force
|
|
783
824
|
if look:
|
|
@@ -1447,6 +1488,22 @@ class Sidebar:
|
|
|
1447
1488
|
lane_id = entry.get('id')
|
|
1448
1489
|
return bool(lane_id) and not entry.get('divider') and any(e.get('id') == lane_id for e in self.entries)
|
|
1449
1490
|
|
|
1491
|
+
def workspace_docs(self):
|
|
1492
|
+
"""This workspace's documents (plan W10 W7): the `docs.n` of every lane it lists — registry lanes (hidden ones
|
|
1493
|
+
too: their documents exist), zmx lanes and the sessions under Unregistered — each id once. reload() keeps it
|
|
1494
|
+
(`docs_total`) for the upload strip; counts are every login's (DM-LANES-DOCS-COUNTS), the page shows a login
|
|
1495
|
+
only what it may see."""
|
|
1496
|
+
ids = {entry.get('id') for rows in (self.entries, self.zmx, self.unregistered) for entry in rows or ()
|
|
1497
|
+
if isinstance(entry, dict)}
|
|
1498
|
+
return sum(docs_count(self.status.get(lane_id)) for lane_id in ids if lane_id)
|
|
1499
|
+
|
|
1500
|
+
def lane_docs_link(self, lane_id):
|
|
1501
|
+
"""The OSC 8 target of a lane's name on the cursor row: its documents on the docs page, '' when the lane has
|
|
1502
|
+
none or no origin is known (plan W10 W7)."""
|
|
1503
|
+
if not self.web_origin or not docs_count(self.status.get(lane_id)):
|
|
1504
|
+
return ''
|
|
1505
|
+
return docs_url(self.web_origin, self.workspace, lane_id)
|
|
1506
|
+
|
|
1450
1507
|
def header_tally(self):
|
|
1451
1508
|
"""[(agent status, 'glyph+count')] for the header, in `HEADER_COUNTS` order, zeros omitted; a seen `done`
|
|
1452
1509
|
is not counted (it left the Done section)."""
|
|
@@ -1516,8 +1573,11 @@ class Sidebar:
|
|
|
1516
1573
|
viewers = viewer_mark(observed).replace('·', self.glyphs['marks']['viewer'])
|
|
1517
1574
|
closable = bool(entry.get('closable')) and not pending # a row being registered offers neither control
|
|
1518
1575
|
can_register = closable and R.eligible(entry)
|
|
1576
|
+
# Plan W10 W7: the cursor row's name is one OSC 8 link to its lane's documents while it has any.
|
|
1577
|
+
link = self.lane_docs_link(entry['id']) if is_cursor else ''
|
|
1519
1578
|
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
|
|
1579
|
+
viewers, status, age, tail, tail_role, is_cursor, is_viewing, mark, mark_role, cols, section_tone, hidden,
|
|
1580
|
+
link)
|
|
1521
1581
|
cached = self.row_cache.get(entry['id'])
|
|
1522
1582
|
if cached and cached[0] == key:
|
|
1523
1583
|
self.row_hits += 1
|
|
@@ -1552,7 +1612,7 @@ class Sidebar:
|
|
|
1552
1612
|
fg = (tone['ink-faint'] if hidden else f'\033[38;5;{color}m' if isinstance(color, int) and self.color != 'none'
|
|
1553
1613
|
else tone['ink'] if self.vivid else '')
|
|
1554
1614
|
lead = (tone['bold'] if is_viewing else '') + fg
|
|
1555
|
-
name_cell = f'{lead}{clipped}{reset}' if lead else clipped
|
|
1615
|
+
name_cell = osc8(link, f'{lead}{clipped}{reset}' if lead else clipped)
|
|
1556
1616
|
if viewers:
|
|
1557
1617
|
name_cell += f'{tone["ink-dim"]}{viewers}{reset}'
|
|
1558
1618
|
# The `+` register control (README "Daily use") has no column in spec §4.2: it takes the spacer cell 28
|
|
@@ -2493,6 +2553,27 @@ class Sidebar:
|
|
|
2493
2553
|
self.message = f'Shown {name}' if hidden else f'Hid {name} {g["sep"]} o shows it'
|
|
2494
2554
|
self.release_focus()
|
|
2495
2555
|
|
|
2556
|
+
def show_docs_link(self, entry):
|
|
2557
|
+
"""`d` (plan W10 W7, LNS-108): the cursor lane's documents link in the footer — a message like every other
|
|
2558
|
+
(MESSAGE_S, or until the next action): the plain URL `https://<host>:8443/lanes-docs/?ws=<ws>&lane=<lane>`, as
|
|
2559
|
+
much of it as the row holds, the whole footer text one OSC 8 link to all of it (⌘/Ctrl-click, or the
|
|
2560
|
+
terminal's Copy Link, where 36 cells clip the text). Without an origin the path alone, no link. Then the
|
|
2561
|
+
keyboard goes back to the lane, as after `p`; a row with no documents, or no lane, says so and keeps it."""
|
|
2562
|
+
self.begin_action()
|
|
2563
|
+
self.dirty = True
|
|
2564
|
+
g = self.glyphs['marks']
|
|
2565
|
+
lane_id = (entry or {}).get('id')
|
|
2566
|
+
if not lane_id or entry.get('divider') or entry.get('action'):
|
|
2567
|
+
self.message = 'no docs here'
|
|
2568
|
+
return
|
|
2569
|
+
if not docs_count(self.status.get(lane_id)):
|
|
2570
|
+
self.message = f'no docs in {g["quote-open"]}{entry.get("name", lane_id)}{g["quote-close"]}'
|
|
2571
|
+
return
|
|
2572
|
+
url = docs_url(self.web_origin, self.workspace, lane_id)
|
|
2573
|
+
self.message = url
|
|
2574
|
+
self._message_link = url if self.web_origin else ''
|
|
2575
|
+
self.release_focus()
|
|
2576
|
+
|
|
2496
2577
|
def toggle_hidden(self):
|
|
2497
2578
|
"""`o`: show the lanes the owner hid (faint, in their places; `h` there shows one for good) or hide them again.
|
|
2498
2579
|
The footer says how many; then the keyboard goes back to the lane."""
|
|
@@ -2819,14 +2900,15 @@ class Sidebar:
|
|
|
2819
2900
|
|
|
2820
2901
|
@message.setter
|
|
2821
2902
|
def message(self, text):
|
|
2822
|
-
# Every assignment is stamped (cockpit v3 S6): the message lasts MESSAGE_S from when it was SAID.
|
|
2823
|
-
|
|
2903
|
+
# Every assignment is stamped (cockpit v3 S6): the message lasts MESSAGE_S from when it was SAID. A new message
|
|
2904
|
+
# is no link until `d` says it is one (plan W10 W7: show_docs_link sets `_message_link` after it).
|
|
2905
|
+
self._message, self.message_at, self._message_link = text, time.monotonic(), ''
|
|
2824
2906
|
|
|
2825
2907
|
def expire_message(self, now=None):
|
|
2826
2908
|
"""run()'s tick (cockpit v3 S6): a message MESSAGE_S old goes -> True when it went (one repaint). Otherwise only
|
|
2827
2909
|
the next action's begin_action() clears it — never a key, a report, the wheel or a release."""
|
|
2828
2910
|
if self._message and (time.monotonic() if now is None else now) - self.message_at >= MESSAGE_S:
|
|
2829
|
-
self._message = ''
|
|
2911
|
+
self._message, self._message_link = '', ''
|
|
2830
2912
|
return True
|
|
2831
2913
|
return False
|
|
2832
2914
|
|
|
@@ -2843,9 +2925,11 @@ class Sidebar:
|
|
|
2843
2925
|
|
|
2844
2926
|
def strip_line(self, cols):
|
|
2845
2927
|
"""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.
|
|
2928
|
+
or the result for 8 s. Every glyph and separator is a mark, so the ASCII frame is seven-bit. Idle while this
|
|
2929
|
+
workspace has documents (plan W10 W7): ` ↑ uploads · ▤ docs N (U)`, the whole text one OSC 8 link to the docs
|
|
2930
|
+
page once statusd knows the origin (the page is where W5's browser uploads land too); with none, today's hint."""
|
|
2847
2931
|
tone, g = self.tone, self.glyphs['marks']
|
|
2848
|
-
sep = f' {g["sep"]} '
|
|
2932
|
+
sep, link = f' {g["sep"]} ', ''
|
|
2849
2933
|
if self.upload:
|
|
2850
2934
|
count = len(self.upload['names'])
|
|
2851
2935
|
text, style = f' {g["upload"]} sending {count} file{"s" if count != 1 else ""}', tone['tone-working']
|
|
@@ -2856,10 +2940,13 @@ class Sidebar:
|
|
|
2856
2940
|
elif self.upload_note and time.time() - self.upload_note_at < 8:
|
|
2857
2941
|
text = ' ' + self.upload_note
|
|
2858
2942
|
style = tone['tone-done'] if self.upload_note.startswith(g['ok']) else tone['tone-urgent']
|
|
2943
|
+
elif self.docs_total:
|
|
2944
|
+
text, style = f' {g["upload"]} uploads{sep}{g["docs"]} docs {self.docs_total} (U)', tone['ink-dim']
|
|
2945
|
+
link = docs_url(self.web_origin) if self.web_origin else ''
|
|
2859
2946
|
else:
|
|
2860
2947
|
text, style = f' {g["upload"]} uploads{sep}click, then drop (U)', tone['ink-dim']
|
|
2861
2948
|
text, _used = clip(text, cols - 1)
|
|
2862
|
-
return f'{style}{text}{tone["reset"]}'
|
|
2949
|
+
return f'{style}{osc8(link, text)}{tone["reset"]}'
|
|
2863
2950
|
|
|
2864
2951
|
def footer_text(self, cols, entries=(), legend=False):
|
|
2865
2952
|
"""The footer row -> (text, style, caret): ONE ladder for the desktop and the phone (cockpit v3 S6; until S6 the
|
|
@@ -2872,9 +2959,11 @@ class Sidebar:
|
|
|
2872
2959
|
(r)` (W9 U6: the installed release lays this view out differently; `r` rebuilds it — the desktop's rung and
|
|
2873
2960
|
the phone's, in the accent) › the focus legend (desktop) › the idle hint. Only the picker's way out, the
|
|
2874
2961
|
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.
|
|
2962
|
+
is seven-bit. `footer_link` says which link footer_row draws this frame: the message's (`d`, plan W10 W7) while
|
|
2963
|
+
the message is the rung shown, else none."""
|
|
2876
2964
|
tone, g = self.tone, self.glyphs['marks']
|
|
2877
2965
|
sep = f' {g["sep"]} '
|
|
2966
|
+
self.footer_link = ''
|
|
2878
2967
|
if self.prompt:
|
|
2879
2968
|
return self.prompt_text(cols)
|
|
2880
2969
|
if self.registration:
|
|
@@ -2892,6 +2981,7 @@ class Sidebar:
|
|
|
2892
2981
|
text, style = f' {g["warning"]} registry: ' + self.errors[0], tone['tone-urgent']
|
|
2893
2982
|
elif self.message:
|
|
2894
2983
|
text, style = ' ' + self.message, tone['ink-dim']
|
|
2984
|
+
self.footer_link = self._message_link
|
|
2895
2985
|
elif self.undo: # cockpit v3 S7: the `u` it undoes, until `z` or UNDO_S
|
|
2896
2986
|
text, style = self.undo_text(cols), tone['ink-dim']
|
|
2897
2987
|
elif self.update_ready: # W9 U6: the installed release lays this view out differently; `r` rebuilds it
|
|
@@ -2942,11 +3032,12 @@ class Sidebar:
|
|
|
2942
3032
|
|
|
2943
3033
|
def footer_row(self, cols, entries=(), legend=False):
|
|
2944
3034
|
"""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.
|
|
3035
|
+
caller ends the row (`\\033[K\\033[J`); the bytes are the ones render() wrote before cockpit v3 S6. `d`'s
|
|
3036
|
+
message (plan W10 W7) is one OSC 8 link to the whole URL, which a 36-cell row clips."""
|
|
2946
3037
|
text, style, caret = self.footer_text(cols, entries, legend)
|
|
2947
3038
|
reset = self.tone['reset']
|
|
2948
3039
|
painted = f'{reset}{self.tone["tone-working"]}{self.glyphs["marks"]["caret"]}{reset}' if caret else ''
|
|
2949
|
-
return f'{style}{text}{painted}{reset}'
|
|
3040
|
+
return f'{style}{osc8(self.footer_link, text)}{painted}{reset}'
|
|
2950
3041
|
|
|
2951
3042
|
def divider_line(self, entry, cols):
|
|
2952
3043
|
"""`── ◆ Needs you (3) ───` (spec §4.4), shared by the desktop and the phone (§4.10 "section headers as
|
|
@@ -3546,6 +3637,8 @@ class Sidebar:
|
|
|
3546
3637
|
self.request_hide_workspace()
|
|
3547
3638
|
elif ch == 'o':
|
|
3548
3639
|
self.toggle_hidden()
|
|
3640
|
+
elif ch == 'd': # plan W10 W7: the cursor lane's docs link in the footer
|
|
3641
|
+
self.show_docs_link(entries[min(self.cursor, len(entries) - 1)] if entries else None)
|
|
3549
3642
|
elif ch == 'P' and entries:
|
|
3550
3643
|
entry = entries[min(self.cursor, len(entries) - 1)]
|
|
3551
3644
|
self.start_promote(entry)
|
|
@@ -13,10 +13,15 @@ It never INVENTS a state. "No answer" (a tmux, cgroup, docker or zmx read that t
|
|
|
13
13
|
host) is not an observation: the lane keeps its last published state, marked stale, for at most STALE_MAX seconds
|
|
14
14
|
and is 'unknown' after that. 'absent' / 'missing' are only published when a server answered without the session,
|
|
15
15
|
or positively does not exist.
|
|
16
|
+
|
|
17
|
+
The docs cockpit (plan W10 W7): a lane row carries `docs: {n, at}` while docs/counts.json counts documents for it
|
|
18
|
+
(one stat of that file per cycle), and the document `web_origin`, the https origin the docs page answers on, once
|
|
19
|
+
main() has learnt it between two cycles (refresh_origin).
|
|
16
20
|
"""
|
|
17
21
|
import concurrent.futures
|
|
18
22
|
import json
|
|
19
23
|
import os
|
|
24
|
+
import re
|
|
20
25
|
import subprocess
|
|
21
26
|
import sys
|
|
22
27
|
import time
|
|
@@ -27,6 +32,7 @@ import lanes_lib as L # noqa: E402
|
|
|
27
32
|
import session_actions as A # noqa: E402
|
|
28
33
|
import agent_state as AS # noqa: E402
|
|
29
34
|
import guard as G # noqa: E402
|
|
35
|
+
import docs_store # noqa: E402
|
|
30
36
|
|
|
31
37
|
INTERVAL = float(os.environ.get('LANES_STATUS_INTERVAL', '3'))
|
|
32
38
|
SLOWDOWN = {'ok': 1, 'busy': 2, 'critical': 4} # a host under pressure gets FEWER spawns from us, not the same
|
|
@@ -56,6 +62,16 @@ FIELDS = ['session_name', 'pane_pid', 'pane_dead', 'window_activity', 'pane_curr
|
|
|
56
62
|
'window_bell_flag', 'window_id', 'pane_id', 'pane_title']
|
|
57
63
|
FORMAT = '\t'.join('#{' + f + '}' for f in FIELDS)
|
|
58
64
|
VIEW_PREFIX = 'view-' # a Throttle Studio browser attach: a grouped session named view-<studio>-<ch>
|
|
65
|
+
# The docs cockpit (plan W10 W7, LNS-108). A lane row carries `docs: {n, at}` while docs/counts.json
|
|
66
|
+
# (DM-LANES-DOCS-COUNTS) holds documents for it: ONE stat of that file per cycle, the file read only when it moved.
|
|
67
|
+
# `web_origin` is the https origin Tailscale Serve answers on, the one the enrolment service announces (enrold
|
|
68
|
+
# site_facts' host name: site.conf's LANES_SERVER_NAME, else this node's MagicDNS name), with Serve's port: the sidebar
|
|
69
|
+
# builds its OSC 8 links to /lanes-docs/ on it and never forks to learn it. Resolved outside the cycle (main), at most
|
|
70
|
+
# every ORIGIN_TTL seconds — enrold's own cache of the name — and not again on a host under pressure once known.
|
|
71
|
+
WEB_PORT = 8443 # bin/lanes SERVE_PORT: `tailscale serve --https=8443` -> Caddy :8083 (web/Caddyfile)
|
|
72
|
+
ORIGIN_TTL = 300 # enrold.TAILNET_TTL
|
|
73
|
+
HOST_NAME = re.compile(r'(?=.{1,253}\Z)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
|
|
74
|
+
r'(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\Z', re.ASCII)
|
|
59
75
|
|
|
60
76
|
|
|
61
77
|
def log(where, error):
|
|
@@ -852,6 +868,68 @@ def update_containers(ctx, registry, level):
|
|
|
852
868
|
info['running'] = pid_alive(info.get('pid'))
|
|
853
869
|
|
|
854
870
|
|
|
871
|
+
def docs_counts(ctx):
|
|
872
|
+
"""docs/counts.json as the lane rows carry it -> {lane id: {n, at}} ({} when absent or not valid). ONE os.stat of
|
|
873
|
+
the file per cycle; it is read (docs_store.read_counts: no link followed, no FIFO opened, the shape checked) only
|
|
874
|
+
when its (mtime, inode, size) moved since the last read — docs_store replaces it on every change."""
|
|
875
|
+
try:
|
|
876
|
+
info = os.stat(docs_store.counts_path())
|
|
877
|
+
stamp = (info.st_mtime_ns, info.st_ino, info.st_size)
|
|
878
|
+
except OSError:
|
|
879
|
+
stamp = None
|
|
880
|
+
if stamp != ctx.get('docs_stamp') or 'docs_counts' not in ctx:
|
|
881
|
+
ctx['docs_counts'] = docs_store.read_counts() if stamp else {}
|
|
882
|
+
ctx['docs_stamp'] = stamp
|
|
883
|
+
return ctx['docs_counts']
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def with_docs(lanes, counts):
|
|
887
|
+
"""`lanes` with `docs: {n, at}` on every row counts.json gives documents (n > 0) and on no other row: absent at 0,
|
|
888
|
+
and dropped from a kept row whose lane has none any more. A row that changes is a NEW dict (DEF-1538: a kept row
|
|
889
|
+
may be the very object the published payload holds); the others are passed on as they are."""
|
|
890
|
+
out = {}
|
|
891
|
+
for lane_id, row in lanes.items():
|
|
892
|
+
entry = counts.get(lane_id) if isinstance(counts, dict) else None
|
|
893
|
+
want = {'n': entry['n'], 'at': entry['at']} if isinstance(entry, dict) and entry.get('n') else None
|
|
894
|
+
if not isinstance(row, dict) or row.get('docs') == want:
|
|
895
|
+
out[lane_id] = row
|
|
896
|
+
continue
|
|
897
|
+
row = {key: value for key, value in row.items() if key != 'docs'}
|
|
898
|
+
if want:
|
|
899
|
+
row['docs'] = want
|
|
900
|
+
out[lane_id] = row
|
|
901
|
+
return out
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def resolve_origin():
|
|
905
|
+
"""`https://<name>:8443` — the origin the docs page answers on (Tailscale Serve -> Caddy), by the name enrold
|
|
906
|
+
announces (site_facts: site.conf's LANES_SERVER_NAME, else enrold.tailnet_name(), one bounded `tailscale status
|
|
907
|
+
--json` cached for its TAILNET_TTL), or None when there is no name or it is no DNS host name: it is written into
|
|
908
|
+
terminal OSC strings and links, so nothing but `[A-Za-z0-9.-]` ever reaches them."""
|
|
909
|
+
import enrold # the enrolment service's own reader of the name; imported only when an origin is wanted
|
|
910
|
+
name = enrold.server_site().get('LANES_SERVER_NAME') or enrold.tailnet_name()
|
|
911
|
+
return f'https://{name}:{WEB_PORT}' if isinstance(name, str) and HOST_NAME.match(name) else None
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def refresh_origin(ctx, level, now=None):
|
|
915
|
+
"""main()'s look for `web_origin`, never inside a cycle: at most every ORIGIN_TTL seconds, and on a host that is
|
|
916
|
+
not `ok` only while none is known yet (one bounded spawn, then it waits). -> True when it looked. A failure keeps
|
|
917
|
+
the last origin: an unanswered name is no reason to drop the links."""
|
|
918
|
+
now = time.time() if now is None else now
|
|
919
|
+
known = ctx.get('origin_at') is not None
|
|
920
|
+
if known and (now - ctx['origin_at'] < ORIGIN_TTL or level != 'ok'):
|
|
921
|
+
return False
|
|
922
|
+
ctx['origin_at'] = now
|
|
923
|
+
try:
|
|
924
|
+
origin = resolve_origin()
|
|
925
|
+
except Exception as error:
|
|
926
|
+
log('web origin', error)
|
|
927
|
+
return True
|
|
928
|
+
if origin or not known:
|
|
929
|
+
ctx['web_origin'] = origin
|
|
930
|
+
return True
|
|
931
|
+
|
|
932
|
+
|
|
855
933
|
def build_payload(ctx, level, reconcile=True):
|
|
856
934
|
"""One observation pass -> the payload of status.json (None = there is no registry to observe). Every phase
|
|
857
935
|
fails alone: what it could not observe is republished as last seen (stale), the other phases still run.
|
|
@@ -915,8 +993,15 @@ def build_payload(ctx, level, reconcile=True):
|
|
|
915
993
|
# Outside the payload: it changes every cycle, and status.json is rewritten only on change (plus the heartbeat).
|
|
916
994
|
ctx['zmx_listed_at'] = listed
|
|
917
995
|
lanes.update(zmx_lanes)
|
|
918
|
-
|
|
919
|
-
|
|
996
|
+
try: # plan W10 W7: the docs counts ride on the lane rows (one stat of docs/counts.json per cycle)
|
|
997
|
+
lanes = with_docs(lanes, docs_counts(ctx))
|
|
998
|
+
except Exception as error:
|
|
999
|
+
log('docs', error)
|
|
1000
|
+
payload = {'lanes': lanes, 'servers': servers, 'unregistered': unregistered, 'zmx': zmx_groups,
|
|
1001
|
+
'containers': {k: {'running': v.get('running', False)} for k, v in containers.items()}}
|
|
1002
|
+
if ctx.get('web_origin'): # additive, absent while no origin is known (refresh_origin, main())
|
|
1003
|
+
payload['web_origin'] = ctx['web_origin']
|
|
1004
|
+
return payload
|
|
920
1005
|
|
|
921
1006
|
|
|
922
1007
|
def new_context():
|
|
@@ -927,16 +1012,21 @@ def new_context():
|
|
|
927
1012
|
written = float(seed.get('updated_at') or 0)
|
|
928
1013
|
for entry in (seed.get('lanes') or {}).values():
|
|
929
1014
|
entry.setdefault('stale_since', int(written))
|
|
1015
|
+
# The docs links' origin survives a restart (re-checked by main()'s first refresh_origin); only a valid one.
|
|
1016
|
+
origin = seed.get('web_origin')
|
|
1017
|
+
valid = (isinstance(origin, str) and origin.startswith('https://') and origin.endswith(f':{WEB_PORT}')
|
|
1018
|
+
and HOST_NAME.match(origin[len('https://'):-len(f':{WEB_PORT}')]))
|
|
930
1019
|
return {'registry': None, 'stamp': 0, 'containers': {}, 'containers_at': 0.0,
|
|
931
1020
|
'payload': seed, 'published': None, 'status_at': written, 'cut': 0, 'unwalkable': [],
|
|
932
|
-
'container_status': {}, 'container_status_errors': {}}
|
|
1021
|
+
'container_status': {}, 'container_status_errors': {}, 'web_origin': origin if valid else None}
|
|
933
1022
|
|
|
934
1023
|
|
|
935
1024
|
# ------------------------------------------- the in-container projection (LNS-32)
|
|
936
1025
|
# `~/.lz/status.json` in each workspace container: that container's slice of the payload, for the in-container Agent
|
|
937
1026
|
# API (`lane-mcp`, plan Track M), which cannot read the host's status.json. v1 = {v, at, lanes{<id>: {state, agent?,
|
|
938
|
-
# attached?, viewers?, activity?, size?, stale?}}, unregistered[{id, name, workspace, boards, tmux, identity}]}.
|
|
939
|
-
|
|
1027
|
+
# attached?, viewers?, activity?, size?, stale?, docs?}}, unregistered[{id, name, workspace, boards, tmux, identity}]}.
|
|
1028
|
+
# `docs` (plan W10 W7, additive) is the host row's {n, at} verbatim, absent like there.
|
|
1029
|
+
CONTAINER_STATUS_FIELDS = ('state', 'agent', 'attached', 'viewers', 'activity', 'size', 'stale', 'docs')
|
|
940
1030
|
CONTAINER_STATUS_DIR = tuple(L.CONTAINER_HOME.strip('/').split('/')) + ('.lz',)
|
|
941
1031
|
CONTAINER_STATUS_FILE = 'status.json'
|
|
942
1032
|
|
|
@@ -1147,6 +1237,8 @@ def main():
|
|
|
1147
1237
|
while True:
|
|
1148
1238
|
started = time.time()
|
|
1149
1239
|
level, _payload = cycle(ctx)
|
|
1240
|
+
# The docs links' origin (plan W10 W7): a bounded look between two cycles, never inside one.
|
|
1241
|
+
refresh_origin(ctx, level)
|
|
1150
1242
|
# Hot-reload: check if any terminals.json changed since we last synced.
|
|
1151
1243
|
# Rate-limited to at most one triggered sync per HOT_SYNC_EVERY_S to avoid thrashing.
|
|
1152
1244
|
if time.time() - last_triggered_sync > HOT_SYNC_EVERY_S and ctx['registry']:
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""The Lanes design tokens: ONE table, rendered
|
|
3
|
-
none); `--kdl`, `--css`, `--sh`, `--md`, `--js
|
|
4
|
-
CSS, the viewer's shell colour sets, the docs tables, the phone's terminal palette
|
|
5
|
-
files between markers; `--check` fails when any committed block
|
|
2
|
+
"""The Lanes design tokens: ONE table, rendered eight ways. The sidebar imports `sgr_table` (truecolor, 256, 16 or
|
|
3
|
+
none); `--kdl`, `--css`, `--sh`, `--md`, `--js`, `--ghostty-dark|--ghostty-light` and `--docs-css` write the Zellij
|
|
4
|
+
theme, the phone CSS, the viewer's shell colour sets, the docs tables, the phone's terminal palette, the optional
|
|
5
|
+
Ghostty palette files and the docs page's CSS (plan W10 W6) between markers; `--check` fails when any committed block
|
|
6
|
+
differs. Spec:
|
|
6
7
|
docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md; palette v2 + the 256-colour set: cockpit v3 P1 (LNS-69).
|
|
7
8
|
No third-party code; importable with no side effects."""
|
|
8
9
|
import difflib
|
|
@@ -89,7 +90,10 @@ MARKS = {'cursor': ('▌', '|', 'accent'), 'viewing': ('›', '=', 'accent'), 'v
|
|
|
89
90
|
# more`. The lowest of column 3's marks: pending, the viewports and the viewing mark win over it. Seven-bit
|
|
90
91
|
# `^` is free in column 3 (`=` `2` `3` `~` are the others; the upload strip's `^` is no column-3 mark).
|
|
91
92
|
'pinned': ('▲', '^', 'accent'),
|
|
92
|
-
'ok': ('✓', 'OK', 'tone-done'), 'fail': ('✗', 'X', 'tone-urgent')
|
|
93
|
+
'ok': ('✓', 'OK', 'tone-done'), 'fail': ('✗', 'X', 'tone-urgent'),
|
|
94
|
+
# The docs cockpit on the upload strip (` ↑ uploads · ▤ docs 3 (U)`, plan W10 W7): the strip's own tone.
|
|
95
|
+
# Seven-bit `#` is free on that row (`^` uploads, `v` armed, `-` sep are the others).
|
|
96
|
+
'docs': ('▤', '#', 'ink-dim')}
|
|
93
97
|
ECONOMY_TAIL = {'starving': ('⚠', '!', 'tone-attention'), 'downgraded': ('↓', 'v', 'tone-attention'), 'paid': ('$', '$', 'ink-dim')}
|
|
94
98
|
MOBILE_AGENT_LABELS = {'needs_approval': 'Needs approval', 'needs_input': 'Needs you', 'working': 'Working',
|
|
95
99
|
'done': 'Done', 'errored': 'Error', 'shell': 'Shell', 'idle': 'Idle'}
|
|
@@ -242,6 +246,22 @@ def render_css():
|
|
|
242
246
|
return '\n'.join(out) + '\n'
|
|
243
247
|
|
|
244
248
|
|
|
249
|
+
# The docs page (plan W10 W6, LNS-107; web/docs.css) is its own HTML document, served by lanes-docsd, not the phone
|
|
250
|
+
# shell: no html.lanes-mobile, no Zellij data-zj-theme. Its block puts the roles it paints on :root, dark by default and
|
|
251
|
+
# light under the system's prefers-color-scheme, and only those roles (docs.css has a 2.5 KB cap; the full --css block
|
|
252
|
+
# alone is 2 KB). test_docs_assets pins that docs.css uses exactly these.
|
|
253
|
+
DOCS_ROLES = ('surface', 'surface-raised', 'line', 'ink', 'ink-dim', 'accent')
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def render_docs_css():
|
|
257
|
+
out = ['/* BEGIN lanes tokens (generated by libexec/tokens.py --docs-css; do not edit by hand) */', ':root {']
|
|
258
|
+
out += [f' --lanes-{role}: {TOKENS["dark"][role]};' for role in DOCS_ROLES]
|
|
259
|
+
out += [' color-scheme: dark;', '}', '@media (prefers-color-scheme: light) {', ' :root {']
|
|
260
|
+
out += [f' --lanes-{role}: {TOKENS["light"][role]};' for role in DOCS_ROLES]
|
|
261
|
+
out += [' color-scheme: light;', ' }', '}', '/* END lanes tokens */']
|
|
262
|
+
return '\n'.join(out) + '\n'
|
|
263
|
+
|
|
264
|
+
|
|
245
265
|
# The painted roles as the viewer's shell variables (`tokens.sh`), in PAINTED order. LZ_DIM kept its v1 name.
|
|
246
266
|
SH_NAMES = {'ink': 'LZ_INK', 'ink-dim': 'LZ_DIM', 'ink-faint': 'LZ_FAINT', 'accent': 'LZ_ACCENT',
|
|
247
267
|
'tone-urgent': 'LZ_URGENT', 'tone-attention': 'LZ_ATTENTION', 'tone-working': 'LZ_WORKING',
|
|
@@ -333,14 +353,16 @@ def render_ghostty(variant):
|
|
|
333
353
|
|
|
334
354
|
|
|
335
355
|
RENDERERS = {'kdl': render_kdl, 'css': render_css, 'sh': render_sh, 'md': render_md, 'js': render_js,
|
|
336
|
-
'ghostty-dark': lambda: render_ghostty('dark'), 'ghostty-light': lambda: render_ghostty('light')
|
|
356
|
+
'ghostty-dark': lambda: render_ghostty('dark'), 'ghostty-light': lambda: render_ghostty('light'),
|
|
357
|
+
'docs-css': render_docs_css}
|
|
337
358
|
TARGETS = {'kdl': 'zellij/config.kdl', 'css': 'web/mobile.css', 'sh': 'libexec/tokens.sh', 'md': 'docs/DESIGN.md',
|
|
338
359
|
'js': 'web/mobile.js', 'ghostty-dark': 'client/ghostty/lanes-palette.conf',
|
|
339
|
-
'ghostty-light': 'client/ghostty/lanes-palette-light.conf'}
|
|
360
|
+
'ghostty-light': 'client/ghostty/lanes-palette-light.conf', 'docs-css': 'web/docs.css'}
|
|
340
361
|
MARKERS = {'kdl': ('// BEGIN lanes tokens', '// END lanes tokens'), 'css': ('/* BEGIN lanes tokens', '/* END lanes tokens */'),
|
|
341
362
|
'sh': ('# BEGIN lanes tokens', '# END lanes tokens'), 'md': ('<!-- BEGIN lanes tokens', '<!-- END lanes tokens -->'),
|
|
342
363
|
'js': ('// BEGIN lanes tokens', '// END lanes tokens'),
|
|
343
|
-
'ghostty-dark': ('# BEGIN lanes tokens', '# END lanes tokens'), 'ghostty-light': ('# BEGIN lanes tokens', '# END lanes tokens')
|
|
364
|
+
'ghostty-dark': ('# BEGIN lanes tokens', '# END lanes tokens'), 'ghostty-light': ('# BEGIN lanes tokens', '# END lanes tokens'),
|
|
365
|
+
'docs-css': ('/* BEGIN lanes tokens', '/* END lanes tokens */')}
|
|
344
366
|
|
|
345
367
|
|
|
346
368
|
def render(kind):
|
|
@@ -392,7 +414,7 @@ def main(argv):
|
|
|
392
414
|
return 0
|
|
393
415
|
kind = argv[0].lstrip('-') if argv else ''
|
|
394
416
|
if kind not in RENDERERS:
|
|
395
|
-
sys.exit('usage: tokens.py --kdl|--css|--sh|--md|--js|--ghostty-dark|--ghostty-light|--check|--write')
|
|
417
|
+
sys.exit('usage: tokens.py --kdl|--css|--sh|--md|--js|--ghostty-dark|--ghostty-light|--docs-css|--check|--write')
|
|
396
418
|
sys.stdout.write(render(kind))
|
|
397
419
|
return 0
|
|
398
420
|
|