@vibelord/lanes 1.0.0 → 1.0.1
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 +56 -1
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +281 -104
- package/payload/lanes/client/lanes-launch +44 -2
- package/payload/lanes/container/env.sh +8 -0
- package/payload/lanes/container/lane +19 -2
- package/payload/lanes/docs/ARCHITECTURE.md +86 -1
- package/payload/lanes/docs/DESIGN.md +72 -0
- package/payload/lanes/docs/INSTALL.md +39 -0
- package/payload/lanes/docs/RUNBOOK.md +94 -1
- package/payload/lanes/holders/ssh.sh +18 -0
- package/payload/lanes/install/enable-phone.sh +15 -6
- package/payload/lanes/install/enable-web.sh +19 -6
- package/payload/lanes/libexec/agent_state.py +214 -0
- package/payload/lanes/libexec/connection.py +178 -0
- package/payload/lanes/libexec/cursor_sync.py +139 -6
- package/payload/lanes/libexec/device_sync.py +134 -0
- package/payload/lanes/libexec/lanes_lib.py +158 -5
- package/payload/lanes/libexec/registrations.py +148 -0
- package/payload/lanes/libexec/session_actions.py +47 -15
- package/payload/lanes/libexec/sidebar.py +372 -61
- package/payload/lanes/libexec/statusd.py +126 -33
- package/payload/lanes/libexec/tokens.py +279 -0
- package/payload/lanes/libexec/tokens.sh +11 -0
- package/payload/lanes/libexec/view.sh +9 -8
- package/payload/lanes/web/README.md +15 -0
- package/payload/lanes/web/browser.css +22 -0
- package/payload/lanes/web/browser.js +73 -0
- package/payload/lanes/web/mobile.css +65 -33
- package/payload/lanes/web/mobile.js +23 -5
- package/payload/lanes/workspaces.example/README.md +9 -3
- package/payload/lanes/zellij/config.kdl +61 -3
- package/payload/manifest.json +6 -3
|
@@ -13,6 +13,7 @@ or positively does not exist.
|
|
|
13
13
|
"""
|
|
14
14
|
import json
|
|
15
15
|
import os
|
|
16
|
+
import subprocess
|
|
16
17
|
import sys
|
|
17
18
|
import time
|
|
18
19
|
from pathlib import Path
|
|
@@ -20,6 +21,7 @@ from pathlib import Path
|
|
|
20
21
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
21
22
|
import lanes_lib as L # noqa: E402
|
|
22
23
|
import session_actions as A # noqa: E402
|
|
24
|
+
import agent_state as AS # noqa: E402
|
|
23
25
|
import legacy_sessions as Legacy # noqa: E402
|
|
24
26
|
|
|
25
27
|
INTERVAL = float(os.environ.get('LANES_STATUS_INTERVAL', '3'))
|
|
@@ -35,7 +37,12 @@ FIELDS = ['session_name', 'pane_pid', 'pane_dead', 'window_activity', 'pane_curr
|
|
|
35
37
|
'session_id', 'session_created',
|
|
36
38
|
# Alignment contract A4a: the group a session is attached onto (a Throttle Studio browser view is a
|
|
37
39
|
# grouped session), and which pane of which window represents the session's state.
|
|
38
|
-
'session_group', 'pane_active', 'window_active'
|
|
40
|
+
'session_group', 'pane_active', 'window_active',
|
|
41
|
+
# Task 5 (`agent{}`): the window bell, the ids a Claude session file names its pane by
|
|
42
|
+
# (`<session>:@<win>.%<pane>`), and the pane title Codex writes its status into. APPENDED, so every
|
|
43
|
+
# column above keeps its index; the title is the one free-text column and comes LAST, so a tab inside it
|
|
44
|
+
# stays in the title (the row is split at most len(FIELDS) - 1 times) instead of shifting the ids.
|
|
45
|
+
'window_bell_flag', 'window_id', 'pane_id', 'pane_title']
|
|
39
46
|
FORMAT = '\t'.join('#{' + f + '}' for f in FIELDS)
|
|
40
47
|
VIEW_PREFIX = 'view-' # a Throttle Studio browser attach: a grouped session named view-<studio>-<ch>
|
|
41
48
|
|
|
@@ -196,10 +203,11 @@ def keep(previous, lane_id, outcome):
|
|
|
196
203
|
return dict(old, state=state, stale=True, observed=outcome, stale_since=since)
|
|
197
204
|
|
|
198
205
|
|
|
199
|
-
def lane_entry(pane, now, activity=None, viewing=()):
|
|
206
|
+
def lane_entry(pane, now, activity=None, viewing=(), agent=None):
|
|
200
207
|
"""One live session's published row. `activity` is the NEWEST `window_activity` across the session's panes
|
|
201
208
|
(see `newest_activity`) and `viewing` the rows of the sessions that VIEW it (see `split_viewers`); both
|
|
202
|
-
default to "this pane alone", which is what a caller with a single row has.
|
|
209
|
+
default to "this pane alone", which is what a caller with a single row has. `agent` is `agent_state.merge`'s
|
|
210
|
+
verdict for the session's pane: published as the additive `agent` key, absent when no signal names an agent."""
|
|
203
211
|
if activity is None:
|
|
204
212
|
try:
|
|
205
213
|
activity = int(pane['window_activity'])
|
|
@@ -224,9 +232,53 @@ def lane_entry(pane, now, activity=None, viewing=()):
|
|
|
224
232
|
'tty': pane['pane_tty']}
|
|
225
233
|
if viewing: # skipped when 0, so a lane nobody views publishes byte-identically to before A4a
|
|
226
234
|
entry['viewers'] = len(viewing)
|
|
235
|
+
if agent: # absent = no agent: a plain shell's row is byte-identical to before Task 5
|
|
236
|
+
entry['agent'] = agent
|
|
227
237
|
return entry
|
|
228
238
|
|
|
229
239
|
|
|
240
|
+
def agent_signals(containers, sessions_by_server):
|
|
241
|
+
"""The per-container inputs `agent_state.merge` needs, read ONCE per container per cycle through the
|
|
242
|
+
container's root as the host sees it (`/proc/<init pid>/root`), so liveness is judged in the container's pid
|
|
243
|
+
namespace and nothing is spawned. -> {container: claude_by_pane}.
|
|
244
|
+
|
|
245
|
+
Also sets `pane_has_child` on the session rows that need it, in place: only a pane whose title Codex wrote
|
|
246
|
+
(`parse_codex_title`) over a bare-shell foreground can be a harness that ended into its shell, and only the
|
|
247
|
+
child read (`child_liveness`) tells that apart from a live harness launched under `bash -c` or a wrapper. A pane
|
|
248
|
+
a live Claude session file already claims is not probed (the file outranks the title). The key is set only for
|
|
249
|
+
a known answer: unknown liveness leaves it absent, and an absent key never demotes a title (DEF-1420). A
|
|
250
|
+
container whose files cannot be read costs only its own agents, never a lane its `state`."""
|
|
251
|
+
claude = {}
|
|
252
|
+
for container in sorted({container for container, _socket in sessions_by_server}):
|
|
253
|
+
info = containers.get(container) or {}
|
|
254
|
+
if not info.get('pid'):
|
|
255
|
+
continue
|
|
256
|
+
root = Path(L.proc_root()) / str(info['pid']) / 'root'
|
|
257
|
+
try:
|
|
258
|
+
claude[container] = AS.claude_sessions(root / L.CONTAINER_HOME.lstrip('/') / '.claude' / 'sessions',
|
|
259
|
+
root / 'proc')
|
|
260
|
+
except Exception as error:
|
|
261
|
+
log(f'agents {container}', error)
|
|
262
|
+
claude[container] = {}
|
|
263
|
+
probe = [pane for (owner, _socket), sessions in sessions_by_server.items() if owner == container
|
|
264
|
+
for pane in sessions.values()
|
|
265
|
+
if str(pane.get('pane_pid') or '').isdigit() and AS.parse_codex_title(pane.get('pane_title'))
|
|
266
|
+
and AS._shell_foreground(pane)
|
|
267
|
+
and f"{pane.get('session_name')}:{pane.get('window_id')}.{pane.get('pane_id')}" not in claude[container]]
|
|
268
|
+
if not probe:
|
|
269
|
+
continue
|
|
270
|
+
try:
|
|
271
|
+
live = AS.child_liveness(root / 'proc', [pane['pane_pid'] for pane in probe])
|
|
272
|
+
except Exception as error:
|
|
273
|
+
log(f'liveness {container}', error)
|
|
274
|
+
continue
|
|
275
|
+
for pane in probe:
|
|
276
|
+
known = live.get(int(pane['pane_pid']))
|
|
277
|
+
if known is not None:
|
|
278
|
+
pane['pane_has_child'] = known
|
|
279
|
+
return claude
|
|
280
|
+
|
|
281
|
+
|
|
230
282
|
def probe_server(info, socket, live):
|
|
231
283
|
if not info.get('pid'):
|
|
232
284
|
# Only a docker inspect that ANSWERED "pid 0" says the container is down. No record at all, or one that
|
|
@@ -301,7 +353,8 @@ def observe(registry, containers, live, previous=None):
|
|
|
301
353
|
# servers and servers with no registered lane. Never start a server to ask whether it exists.
|
|
302
354
|
for key in discovered_sockets(containers, live):
|
|
303
355
|
wanted.setdefault(key, [])
|
|
304
|
-
|
|
356
|
+
probed = {}
|
|
357
|
+
for container, socket in wanted:
|
|
305
358
|
info = containers.get(container) or {}
|
|
306
359
|
rows_by_session = {}
|
|
307
360
|
# One server (or one odd row) must not cost the other servers their cycle: whatever goes wrong in here
|
|
@@ -309,15 +362,24 @@ def observe(registry, containers, live, previous=None):
|
|
|
309
362
|
try:
|
|
310
363
|
outcome, text = probe_server(info, socket, live)
|
|
311
364
|
for row in text.splitlines() if outcome == 'ok' else []:
|
|
312
|
-
values = dict(zip(FIELDS, row.split('\t')))
|
|
365
|
+
values = dict(zip(FIELDS, row.split('\t', len(FIELDS) - 1)))
|
|
313
366
|
rows_by_session.setdefault(values['session_name'], []).append(values)
|
|
314
367
|
except Exception as error:
|
|
315
368
|
log(f'observe {container}/{socket}', error)
|
|
316
369
|
outcome, rows_by_session = 'error', {}
|
|
317
370
|
# A4a: every session is read through its ACTIVE pane and its NEWEST window activity, the same two
|
|
318
371
|
# readings the Host makes, so `dead`/`active` cannot mean one thing per surface on a multi-window lane.
|
|
319
|
-
|
|
320
|
-
|
|
372
|
+
probed[(container, socket)] = (outcome, {name: session_row(rows) for name, rows in rows_by_session.items()},
|
|
373
|
+
{name: newest_activity(rows) for name, rows in rows_by_session.items()})
|
|
374
|
+
# Task 5: the agent signals of every container, read once per cycle over all of its servers' sessions.
|
|
375
|
+
try:
|
|
376
|
+
claude = agent_signals(containers, {key: sessions for key, (_o, sessions, _a) in probed.items()})
|
|
377
|
+
except Exception as error:
|
|
378
|
+
log('agents', error)
|
|
379
|
+
claude = {}
|
|
380
|
+
for (container, socket), members in wanted.items():
|
|
381
|
+
info = containers.get(container) or {}
|
|
382
|
+
outcome, sessions, activities = probed[(container, socket)]
|
|
321
383
|
# ... and an UNDECLARED session grouped onto another live session is that session's viewer, not a lane.
|
|
322
384
|
registered = {lane['tmux']['session'] for lane in members}
|
|
323
385
|
own_sessions, viewers = split_viewers(sessions, registered)
|
|
@@ -330,7 +392,8 @@ def observe(registry, containers, live, previous=None):
|
|
|
330
392
|
# The sessions nobody registered stay visible as well while their server does not answer.
|
|
331
393
|
for workspace_id, entries in (previous.get('unregistered') or {}).items():
|
|
332
394
|
for entry in entries:
|
|
333
|
-
if entry.get('container') == container and (entry.get('tmux') or {}).get('socket') == socket
|
|
395
|
+
if (entry.get('container') == container and (entry.get('tmux') or {}).get('socket') == socket
|
|
396
|
+
and not any(A.session_key(entry) == A.session_key(lane) for lane in members)):
|
|
334
397
|
unregistered.setdefault(workspace_id, []).append(entry)
|
|
335
398
|
lanes[entry['id']] = keep(previous, entry['id'], outcome)
|
|
336
399
|
if own_sessions:
|
|
@@ -380,7 +443,8 @@ def observe(registry, containers, live, previous=None):
|
|
|
380
443
|
# RUNBOOK nor the design doc may promise that it does.
|
|
381
444
|
if not L.TMUX_SESSION.match(name):
|
|
382
445
|
entry['note'] = 'session name outside the lane declaration grammar'
|
|
383
|
-
found = lane_entry(pane, now, activities.get(name), viewers.get(name, [])
|
|
446
|
+
found = lane_entry(pane, now, activities.get(name), viewers.get(name, []),
|
|
447
|
+
AS.merge(pane, claude.get(container, {}), None))
|
|
384
448
|
except Exception as error:
|
|
385
449
|
log(f'observe {container}/{socket}/{name}', error)
|
|
386
450
|
continue
|
|
@@ -398,7 +462,9 @@ def observe(registry, containers, live, previous=None):
|
|
|
398
462
|
lanes[lane['id']] = {'state': 'absent' if info.get('running') else 'missing'}
|
|
399
463
|
else:
|
|
400
464
|
try:
|
|
401
|
-
|
|
465
|
+
# The hook sidecar (Task 8) is the third argument; until it lands nothing is passed.
|
|
466
|
+
lanes[lane['id']] = lane_entry(pane, now, activities.get(session), viewers.get(session, []),
|
|
467
|
+
AS.merge(pane, claude.get(container, {}), None))
|
|
402
468
|
except Exception as error: # a torn row: the session is there, its details are not readable
|
|
403
469
|
log(f"observe {lane['id']}", error)
|
|
404
470
|
lanes[lane['id']] = keep(previous, lane['id'], 'error')
|
|
@@ -591,7 +657,7 @@ def update_containers(ctx, registry, level):
|
|
|
591
657
|
5-minute safety net on a host that is not busy — and at most every CONTAINER_RETRY seconds. NEVER because a
|
|
592
658
|
probe failed: `docker inspect` (2 spawns, 20 s timeouts) every 3 s is a load generator on a host that is
|
|
593
659
|
already too slow to answer it."""
|
|
594
|
-
names = {lane['container'] for w in registry['workspaces'] for lane in w['lanes'] + w['extras']}
|
|
660
|
+
names = {lane['container'] for w in registry['workspaces'] for lane in w['lanes'] + w['extras'] if lane.get('container')}
|
|
595
661
|
known = ctx['containers']
|
|
596
662
|
age = time.time() - ctx['containers_at']
|
|
597
663
|
unsettled = set(known) != names or any(not pid_alive(c.get('pid')) for c in known.values())
|
|
@@ -713,42 +779,69 @@ def pause(level, elapsed):
|
|
|
713
779
|
return max(0.5, INTERVAL * SLOWDOWN.get(level, 1) - elapsed)
|
|
714
780
|
|
|
715
781
|
|
|
716
|
-
def
|
|
717
|
-
"""
|
|
718
|
-
|
|
782
|
+
def registry_source_paths(registry, containers):
|
|
783
|
+
"""(container path, HOST path) for every terminals.json the registry references. The daemon runs on the
|
|
784
|
+
host: a lane's `source` is spelled as the container sees it and only exists under the container's home
|
|
785
|
+
volume (`L.host_path`). A container with no record is skipped — nobody can stat its files anyway.
|
|
786
|
+
|
|
787
|
+
Deduplicated by the HOST path, never by `source` alone: the same spelling in two containers (one repository
|
|
788
|
+
cloned in two workspace boxes) is two distinct files, and each must be stat'ed on its own."""
|
|
789
|
+
seen, out = set(), []
|
|
719
790
|
for w in (registry or {}).get('workspaces', []):
|
|
720
791
|
for lane in w.get('lanes', []):
|
|
721
792
|
src = lane.get('source')
|
|
722
|
-
if
|
|
723
|
-
|
|
724
|
-
|
|
793
|
+
if not src:
|
|
794
|
+
continue
|
|
795
|
+
info = (containers or {}).get(lane.get('container') or '') or {}
|
|
796
|
+
try:
|
|
797
|
+
path = L.host_path(info, src)
|
|
798
|
+
except L.LanesError:
|
|
799
|
+
continue
|
|
800
|
+
if path in seen:
|
|
801
|
+
continue
|
|
802
|
+
seen.add(path)
|
|
803
|
+
out.append((src, path))
|
|
804
|
+
return out
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def hot_sync(ctx, source_mtimes):
|
|
808
|
+
"""One hot-reload check: start ONE `lanes sync` when a referenced terminals.json changed since the last
|
|
809
|
+
check (read through the host volume). Returns True when a sync was started. The new mtime is recorded
|
|
810
|
+
once the sync is started, so one edit costs one sync — not one every rate-limit window forever. Keyed by the
|
|
811
|
+
host path: a `source` spelling is shared by every container that clones the same repository."""
|
|
812
|
+
for _src, path in registry_source_paths(ctx['registry'], ctx.get('containers') or {}):
|
|
813
|
+
key = str(path)
|
|
814
|
+
try:
|
|
815
|
+
mtime = path.stat().st_mtime_ns
|
|
816
|
+
except OSError:
|
|
817
|
+
continue
|
|
818
|
+
if key in source_mtimes and mtime != source_mtimes[key]:
|
|
819
|
+
try:
|
|
820
|
+
proc = subprocess.Popen([str(L.REPO / 'bin' / 'lanes'), 'sync'],
|
|
821
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
822
|
+
except Exception as sync_err:
|
|
823
|
+
log('hot-sync', sync_err)
|
|
824
|
+
return False
|
|
825
|
+
ctx.setdefault('children', []).append(proc)
|
|
826
|
+
source_mtimes[key] = mtime
|
|
827
|
+
return True
|
|
828
|
+
source_mtimes[key] = mtime
|
|
829
|
+
return False
|
|
725
830
|
|
|
726
831
|
|
|
727
832
|
def main():
|
|
728
833
|
ctx = new_context()
|
|
729
834
|
last_triggered_sync = 0.0
|
|
730
|
-
source_mtimes = {} # path -> mtime_ns at last check
|
|
835
|
+
source_mtimes = {} # host path of a terminals.json -> its mtime_ns at last check
|
|
731
836
|
while True:
|
|
732
837
|
started = time.time()
|
|
733
838
|
level, _payload = cycle(ctx)
|
|
734
839
|
# Hot-reload: check if any terminals.json changed since we last synced.
|
|
735
840
|
# Rate-limited to at most one triggered sync per 10 s to avoid thrashing.
|
|
736
841
|
if time.time() - last_triggered_sync > 10 and ctx['registry']:
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
except OSError:
|
|
741
|
-
continue
|
|
742
|
-
if src in source_mtimes and mtime != source_mtimes[src]:
|
|
743
|
-
try:
|
|
744
|
-
import subprocess as _sp
|
|
745
|
-
lanes_bin = str(L.REPO / 'bin' / 'lanes')
|
|
746
|
-
_sp.Popen([lanes_bin, 'sync'], stdout=_sp.DEVNULL, stderr=_sp.DEVNULL)
|
|
747
|
-
last_triggered_sync = time.time()
|
|
748
|
-
except Exception as sync_err:
|
|
749
|
-
log('hot-sync', sync_err)
|
|
750
|
-
break
|
|
751
|
-
source_mtimes[src] = mtime
|
|
842
|
+
if hot_sync(ctx, source_mtimes):
|
|
843
|
+
last_triggered_sync = time.time()
|
|
844
|
+
ctx['children'] = [p for p in ctx.get('children', []) if p.poll() is None] # reap finished syncs
|
|
752
845
|
time.sleep(pause(level, time.time() - started))
|
|
753
846
|
|
|
754
847
|
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The Lanes design tokens: ONE table, rendered four ways. The sidebar imports `sgr_table`; `--kdl`, `--css`, `--sh`
|
|
3
|
+
and `--md` write the Zellij theme, the phone CSS, the viewer's shell variables and the docs table between markers;
|
|
4
|
+
`--check` fails when any committed block differs. Spec: docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md.
|
|
5
|
+
No third-party code; importable with no side effects."""
|
|
6
|
+
import difflib
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
ROLES = ('surface', 'surface-raised', 'line', 'accent-wash', 'accent-line', 'press', 'chip',
|
|
12
|
+
'ink', 'ink-dim', 'ink-faint', 'accent', 'on-accent', 'on-accent-urgent',
|
|
13
|
+
'tone-urgent', 'tone-attention', 'tone-working', 'tone-done', 'scrim', 'shadow')
|
|
14
|
+
TOKENS = {
|
|
15
|
+
'dark': {'surface': '#101318', 'surface-raised': '#191e25', 'line': '#2c333d', 'accent-wash': '#1a2721',
|
|
16
|
+
'accent-line': '#4b6955', 'press': '#26332c', 'chip': '#ffffff0f', 'ink': '#edf1f5', 'ink-dim': '#9ca7b5',
|
|
17
|
+
'ink-faint': '#6b7683', 'accent': '#b4e3bf', 'on-accent': '#101318', 'on-accent-urgent': '#7a1a12',
|
|
18
|
+
'tone-urgent': '#f28b7d', 'tone-attention': '#e5b84a', 'tone-working': '#7fb3f2', 'tone-done': '#6fd39a',
|
|
19
|
+
'scrim': '#000000a6', 'shadow': '0 -16px 80px #0007'},
|
|
20
|
+
'light': {'surface': '#f3f5f2', 'surface-raised': '#ffffff', 'line': '#d5ded6', 'accent-wash': '#e5f0e6',
|
|
21
|
+
'accent-line': '#acc9b4', 'press': '#d8e9db', 'chip': '#edf3ec', 'ink': '#17241b', 'ink-dim': '#56616d',
|
|
22
|
+
'ink-faint': '#7f8a96', 'accent': '#2f6b45', 'on-accent': '#ffffff', 'on-accent-urgent': '#ffd3cc',
|
|
23
|
+
'tone-urgent': '#b7322a', 'tone-attention': '#8a5a00', 'tone-working': '#2456a8', 'tone-done': '#1b7142',
|
|
24
|
+
'scrim': '#1a222b80', 'shadow': '0 -16px 80px #0003'},
|
|
25
|
+
}
|
|
26
|
+
# 16-colour fallback: the user's own palette resolves these. Selection is achromatic (bold) there, dim is bright-black.
|
|
27
|
+
ANSI16 = {'ink': '39', 'ink-dim': '90', 'ink-faint': '90', 'accent': '1;39',
|
|
28
|
+
'tone-urgent': '31', 'tone-attention': '33', 'tone-working': '34', 'tone-done': '32'}
|
|
29
|
+
# Lane-identity palette for the two places that DEFINE a palette: Zellij's `web_client { theme {} }` (the phone's
|
|
30
|
+
# xterm.js; dark only, see _web_client) and the optional Ghostty file. A Zellij theme's legacy `black…white` keys are
|
|
31
|
+
# NOT one of them (DEF-1413). The TUI itself keeps 38;5;N. Slots 1-4 are the four tones. Every slot >= 4.5:1.
|
|
32
|
+
PALETTE16 = {
|
|
33
|
+
'dark': ['#7d8896', '#f28b7d', '#6fd39a', '#e5b84a', '#7fb3f2', '#c9a6ff', '#6fd6d0', '#9ca7b5',
|
|
34
|
+
'#8a95a3', '#ff9d96', '#8fe0a3', '#f7cf7f', '#a5cbff', '#dcc6ff', '#9ae6e0', '#edf1f5'],
|
|
35
|
+
'light': ['#3b4a42', '#b7322a', '#1b7142', '#8a5a00', '#2456a8', '#7a3fb8', '#0f7b7b', '#56616d',
|
|
36
|
+
'#5f6d66', '#c2332a', '#1b7a38', '#8f5a00', '#2860c4', '#8a4dc8', '#0f7272', '#17241b'],
|
|
37
|
+
}
|
|
38
|
+
# key -> (unicode, ascii twin, tone). Position disambiguates: agent glyphs in column 2, stream glyphs in column 33.
|
|
39
|
+
AGENT_GLYPHS = {'needs_approval': ('◆', '!', 'tone-urgent'), 'needs_input': ('◇', '?', 'tone-attention'),
|
|
40
|
+
'working': ('▸', '>', 'tone-working'), 'done': ('✓', 'v', 'tone-done'),
|
|
41
|
+
'errored': ('✗', 'X', 'tone-urgent'), 'shell': ('_', '_', 'ink-dim'), 'idle': ('·', '.', 'ink-dim')}
|
|
42
|
+
GLYPHS = {'active': ('●', '*', 'tone-done'), 'quiet': ('○', 'o', 'ink-dim'), 'missing': ('✗', 'x', 'tone-urgent'),
|
|
43
|
+
'absent': ('–', '-', 'ink-dim'), 'dead': ('!', '!', 'tone-urgent'), 'helper': ('·', '.', 'ink-dim'),
|
|
44
|
+
'unknown': ('?', '?', 'ink-dim')}
|
|
45
|
+
MARKS = {'cursor': ('▌', '|', 'accent'), 'viewing': ('›', '=', 'accent'), 'viewport-b': ('²', '2', 'accent'),
|
|
46
|
+
'viewport-c': ('³', '3', 'accent'), 'viewer': ('·', '.', 'ink-dim'), 'truncation': ('…', '~', 'ink'),
|
|
47
|
+
'close': ('×', 'x', 'tone-urgent'), 'new': ('+', '+', 'tone-done'), 'rule': ('─', '-', 'ink-faint'),
|
|
48
|
+
'more': ('⋮', ':', 'ink-dim'), 'warning': ('⚠', '!', 'tone-attention'), 'upload': ('↑', '^', 'ink-dim'),
|
|
49
|
+
'armed': ('↓', 'v', 'tone-done'), 'enter': ('⏎', 'Enter', 'ink-dim'), 'caret': ('█', '_', 'tone-working'),
|
|
50
|
+
'stale': ('?', '?', 'ink-dim'), 'lead': ('›', '>', 'ink'),
|
|
51
|
+
# Tone-less punctuation (spec §3 completeness rule): every ` · ` and every quote the sidebar draws is a lookup, so
|
|
52
|
+
# LANES_GLYPHS=ascii yields a seven-bit frame. `sep` shares `-` with `rule` on purpose: neither carries state.
|
|
53
|
+
'sep': ('·', '-', 'ink-dim'), 'quote-open': ('‘', "'", 'ink'), 'quote-close': ('’', "'", 'ink'),
|
|
54
|
+
'ok': ('✓', 'OK', 'tone-done'), 'fail': ('✗', 'X', 'tone-urgent')}
|
|
55
|
+
ECONOMY_TAIL = {'starving': ('⚠', '!', 'tone-attention'), 'downgraded': ('↓', 'v', 'tone-attention'), 'paid': ('$', '$', 'ink-dim')}
|
|
56
|
+
MOBILE_AGENT_LABELS = {'needs_approval': 'Needs approval', 'needs_input': 'Needs you', 'working': 'Working',
|
|
57
|
+
'done': 'Done', 'errored': 'Error', 'shell': 'Shell', 'idle': 'Idle'}
|
|
58
|
+
PAINTED = tuple(ANSI16) # the roles the TUI ever emits
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def luminance(hexval):
|
|
62
|
+
r, g, b = (int(hexval[i:i + 2], 16) / 255 for i in (1, 3, 5))
|
|
63
|
+
lin = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in (r, g, b)]
|
|
64
|
+
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def contrast(fg, bg):
|
|
68
|
+
hi, lo = sorted((luminance(fg), luminance(bg)), reverse=True)
|
|
69
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def rgb(hexval):
|
|
73
|
+
return tuple(int(hexval[i:i + 2], 16) for i in (1, 3, 5))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def sgr_table(mode, variant):
|
|
77
|
+
"""Complete escape per painted role. `mode` truecolor|16|none; `variant` dark|light. Resolved ONCE per process."""
|
|
78
|
+
table = {'bold': '\033[1m', 'reset': '\033[0m'}
|
|
79
|
+
dim16 = '\033[2m' if os.environ.get('LANES_DIM') == '2' else '\033[90m'
|
|
80
|
+
for role in PAINTED:
|
|
81
|
+
if mode == 'none':
|
|
82
|
+
table[role] = '\033[1m' if role == 'accent' else ''
|
|
83
|
+
elif mode == '16':
|
|
84
|
+
table[role] = dim16 if role in ('ink-dim', 'ink-faint') else f'\033[{ANSI16[role]}m'
|
|
85
|
+
else:
|
|
86
|
+
table[role] = ('\033[1m' if role == 'accent' else '') + '\033[38;2;%d;%d;%dm' % rgb(TOKENS[variant][role])
|
|
87
|
+
return table
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def glyph_set(mode):
|
|
91
|
+
"""mode unicode|ascii|emoji -> {table name: {key: glyph}}; emoji differs from unicode only in the icon column."""
|
|
92
|
+
index = 1 if mode == 'ascii' else 0
|
|
93
|
+
return {name: {k: v[index] for k, v in table.items()}
|
|
94
|
+
for name, table in (('agent', AGENT_GLYPHS), ('stream', GLYPHS), ('marks', MARKS), ('economy', ECONOMY_TAIL))}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def tone_of(table, key):
|
|
98
|
+
return table[key][2]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------ renderers
|
|
102
|
+
COMPONENTS = ('text_unselected', 'text_selected', 'ribbon_selected', 'ribbon_unselected', 'table_title', 'table_cell_selected',
|
|
103
|
+
'table_cell_unselected', 'list_selected', 'list_unselected', 'frame_selected', 'frame_highlight',
|
|
104
|
+
'exit_code_success', 'exit_code_error')
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _component(t, base, background, emphasis):
|
|
108
|
+
e = emphasis
|
|
109
|
+
return f'base "{t[base]}"; background "{t[background]}"; emphasis_0 "{t[e[0]]}"; emphasis_1 "{t[e[1]]}"; emphasis_2 "{t[e[2]]}"; emphasis_3 "{t[e[3]]}";'
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _theme(name, variant):
|
|
113
|
+
t, p = TOKENS[variant], PALETTE16[variant]
|
|
114
|
+
text = ('tone-attention', 'tone-done', 'accent', 'tone-working')
|
|
115
|
+
comps = {
|
|
116
|
+
'text_unselected': ('ink', 'surface', text), 'text_selected': ('ink', 'accent-wash', text),
|
|
117
|
+
'ribbon_selected': ('on-accent', 'accent', ('on-accent-urgent', 'on-accent', 'on-accent', 'on-accent')),
|
|
118
|
+
'ribbon_unselected': ('ink-dim', 'surface-raised', ('tone-urgent', 'ink', 'tone-attention', 'tone-working')),
|
|
119
|
+
'table_title': ('accent', 'surface', text), 'table_cell_selected': ('ink', 'accent-wash', text),
|
|
120
|
+
'table_cell_unselected': ('ink', 'surface', text), 'list_selected': ('ink', 'accent-wash', text),
|
|
121
|
+
'list_unselected': ('ink', 'surface', text),
|
|
122
|
+
'frame_selected': ('accent', 'surface', ('tone-attention', 'tone-done', 'tone-working', 'tone-urgent')),
|
|
123
|
+
'frame_highlight': ('tone-attention', 'surface', ('tone-urgent', 'tone-done', 'accent', 'tone-working')),
|
|
124
|
+
'exit_code_success': ('tone-done', 'surface', ('tone-done',) * 4),
|
|
125
|
+
'exit_code_error': ('tone-urgent', 'surface', ('tone-urgent',) * 4),
|
|
126
|
+
}
|
|
127
|
+
lines = [f' {name} {{']
|
|
128
|
+
for comp in COMPONENTS:
|
|
129
|
+
base, background, emphasis = comps[comp]
|
|
130
|
+
lines.append(f' {comp} {{ {_component(t, base, background, emphasis)} }}')
|
|
131
|
+
players = [t['tone-working'], t['tone-attention'], t['tone-done'], t['tone-urgent'], t['accent'], p[5], p[6], p[11], p[10], p[9]]
|
|
132
|
+
lines.append(' multiplayer_user_colors { ' + ' '.join(f'player_{i + 1} "{c}";' for i, c in enumerate(players)) + ' }')
|
|
133
|
+
# No legacy `fg bg black … orange` line: in a component-format theme Zellij 0.45.1 neither reads nor validates
|
|
134
|
+
# those keys, and none of a theme's colours reach the browser's xterm.js palette (DEF-1413; _web_client does).
|
|
135
|
+
lines.append(' }')
|
|
136
|
+
return lines
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
ANSI_NAMES = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white')
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _web_client():
|
|
143
|
+
"""The phone's terminal palette. Zellij 0.45.1 sends `web_client.theme` to every browser as `SetConfig.theme`,
|
|
144
|
+
which the web client assigns to xterm.js `options.theme` (zellij-client `control_message.rs` / `app.js`
|
|
145
|
+
`terminalConfigOptions`). Without this block only background, foreground and selection fall back to a theme's
|
|
146
|
+
components and the sixteen ANSI slots stay xterm.js's built-in defaults. The block is server-wide and holds ONE
|
|
147
|
+
palette: the dark variant, the phone's default. It reaches the browser in the HTTP boot config at connect (the
|
|
148
|
+
xterm.js `Terminal` constructor) and in a `SetConfig` on every config-file reload, which the client assigns
|
|
149
|
+
wholesale before repainting `<body>`/`#terminal` inline, so the phone's light toggle needs a client-side
|
|
150
|
+
`term.options.theme` override applied on attach and re-applied after each `SetConfig`, plus a CSS background rule,
|
|
151
|
+
not set once (spec §6.3, Task 28; DEF-1413). Zellij validates every colour here (an unparseable one fails
|
|
152
|
+
`setup --check`), so `test_tokens` can prove the block is read."""
|
|
153
|
+
t, p = TOKENS['dark'], PALETTE16['dark']
|
|
154
|
+
lines = ['web_client {', ' font "monospace"', ' theme {',
|
|
155
|
+
f' background "{t["surface"]}"', f' foreground "{t["ink"]}"']
|
|
156
|
+
lines += [f' {n} "{p[i]}"' for i, n in enumerate(ANSI_NAMES)]
|
|
157
|
+
lines += [f' bright_{n} "{p[i + 8]}"' for i, n in enumerate(ANSI_NAMES)]
|
|
158
|
+
lines += [f' selection_background "{t["accent-wash"]}"', f' selection_foreground "{t["ink"]}"']
|
|
159
|
+
lines += [' }', '}']
|
|
160
|
+
return lines
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def render_kdl():
|
|
164
|
+
lines = ['// BEGIN lanes tokens (generated by libexec/tokens.py --kdl; do not edit by hand)', 'themes {']
|
|
165
|
+
lines += _theme('lanes-dark', 'dark') + _theme('lanes-light', 'light')
|
|
166
|
+
lines += ['}', 'theme_dark "lanes-dark"', 'theme_light "lanes-light"']
|
|
167
|
+
lines += _web_client() + ['// END lanes tokens']
|
|
168
|
+
return '\n'.join(lines) + '\n'
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
ALIASES = {'bg': 'surface', 'panel': 'surface-raised', 'border': 'line', 'text': 'ink', 'muted': 'ink-dim',
|
|
172
|
+
'current': 'accent-wash', 'current-border': 'accent-line'}
|
|
173
|
+
ZJ = {'bg': 'surface', 'surface': 'surface-raised', 'border': 'line', 'text': 'ink', 'text-dim': 'ink-dim',
|
|
174
|
+
'green': 'tone-done', 'blue': 'tone-working', 'red': 'tone-urgent', 'yellow': 'tone-attention'}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def render_css():
|
|
178
|
+
"""Values per variant; every `var()` indirection (legacy aliases, upstream `--zj-*`) ONCE. All three selectors
|
|
179
|
+
match the same <html> element and a custom property's var() resolves against that element's winning value, so a
|
|
180
|
+
single alias follows the light override exactly like a per-variant copy did — 289 B less of the phone's mobile.css
|
|
181
|
+
growth allowance (spec §8; DEF-1414)."""
|
|
182
|
+
out = ['/* BEGIN lanes tokens (generated by libexec/tokens.py --css; do not edit by hand) */']
|
|
183
|
+
for selector, variant, scheme in (('html.lanes-mobile', 'dark', 'dark'), ('html.lanes-mobile:root[data-zj-theme="light"]', 'light', 'light')):
|
|
184
|
+
out.append(selector + ' {')
|
|
185
|
+
out += [f' --lanes-{role}: {TOKENS[variant][role]};' for role in ROLES]
|
|
186
|
+
out.append(f' color-scheme: {scheme};')
|
|
187
|
+
out.append('}')
|
|
188
|
+
out.append('html.lanes-mobile:root {')
|
|
189
|
+
out += [f' --lanes-{old}: var(--lanes-{new});' for old, new in ALIASES.items()]
|
|
190
|
+
out += [f' --zj-{k}: var(--lanes-{v});' for k, v in ZJ.items()]
|
|
191
|
+
out.append('}')
|
|
192
|
+
out.append('/* END lanes tokens */')
|
|
193
|
+
return '\n'.join(out) + '\n'
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def render_sh():
|
|
197
|
+
return ('# BEGIN lanes tokens (generated by libexec/tokens.py --sh; do not edit by hand)\n'
|
|
198
|
+
"LZ_BOLD='\\033[1m'\nLZ_DIM='\\033[90m'\n"
|
|
199
|
+
f"LZ_URGENT='\\033[{ANSI16['tone-urgent']}m'\nLZ_ATTENTION='\\033[{ANSI16['tone-attention']}m'\n"
|
|
200
|
+
f"LZ_WORKING='\\033[{ANSI16['tone-working']}m'\nLZ_DONE='\\033[{ANSI16['tone-done']}m'\nLZ_RESET='\\033[0m'\n"
|
|
201
|
+
'# END lanes tokens\n')
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def render_md():
|
|
205
|
+
rows = ['<!-- BEGIN lanes tokens (generated by libexec/tokens.py --md; do not edit by hand) -->',
|
|
206
|
+
'| Role | Dark | Light | ANSI-16 | CSS variable |', '|---|---|---|---|---|']
|
|
207
|
+
rows += [f'| {r} | `{TOKENS["dark"][r]}` | `{TOKENS["light"][r]}` | `{ANSI16.get(r, "—")}` | `--lanes-{r}` |' for r in ROLES]
|
|
208
|
+
rows += ['', '| Glyph | Codepoint | ASCII | Tone | Meaning |', '|---|---|---|---|---|']
|
|
209
|
+
for name, table in (('agent', AGENT_GLYPHS), ('stream', GLYPHS), ('mark', MARKS), ('economy', ECONOMY_TAIL)):
|
|
210
|
+
for key, (glyph, ascii_twin, tone) in table.items():
|
|
211
|
+
cp = ' '.join(f'U+{ord(c):04X}' for c in glyph)
|
|
212
|
+
cell = ascii_twin.replace('|', '\\|') # a bare pipe splits a GFM table cell even inside backticks
|
|
213
|
+
rows.append(f'| `{glyph}` | {cp} | `{cell}` | {tone} | {name} `{key}` |')
|
|
214
|
+
rows.append('<!-- END lanes tokens -->')
|
|
215
|
+
return '\n'.join(rows) + '\n'
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
RENDERERS = {'kdl': render_kdl, 'css': render_css, 'sh': render_sh, 'md': render_md}
|
|
219
|
+
TARGETS = {'kdl': 'zellij/config.kdl', 'css': 'web/mobile.css', 'sh': 'libexec/tokens.sh', 'md': 'docs/DESIGN.md'}
|
|
220
|
+
MARKERS = {'kdl': ('// BEGIN lanes tokens', '// END lanes tokens'), 'css': ('/* BEGIN lanes tokens', '/* END lanes tokens */'),
|
|
221
|
+
'sh': ('# BEGIN lanes tokens', '# END lanes tokens'), 'md': ('<!-- BEGIN lanes tokens', '<!-- END lanes tokens -->')}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def render(kind):
|
|
225
|
+
return RENDERERS[kind]()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _splice(text, kind):
|
|
229
|
+
"""Replace the marker block in `text` with the renderer's output; append when no block exists yet."""
|
|
230
|
+
begin, end = MARKERS[kind]
|
|
231
|
+
block = render(kind)
|
|
232
|
+
start = text.find(begin)
|
|
233
|
+
if start < 0:
|
|
234
|
+
return text + ('' if text.endswith('\n') or not text else '\n') + block
|
|
235
|
+
stop = text.find(end, start)
|
|
236
|
+
stop = text.find('\n', stop) + 1 if stop >= 0 else len(text)
|
|
237
|
+
return text[:start] + block + text[stop:]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def check(repo):
|
|
241
|
+
"""Paths (relative to `repo`) whose committed marker block is not what the renderer emits. A target that is
|
|
242
|
+
missing or has no block yet is reported too: an unadopted surface is drift, not a skip."""
|
|
243
|
+
drifted = []
|
|
244
|
+
for kind, rel in TARGETS.items():
|
|
245
|
+
path = Path(repo) / rel
|
|
246
|
+
try:
|
|
247
|
+
text = path.read_text()
|
|
248
|
+
except OSError:
|
|
249
|
+
drifted.append(rel)
|
|
250
|
+
continue
|
|
251
|
+
if _splice(text, kind) != text:
|
|
252
|
+
drifted.append(rel)
|
|
253
|
+
return drifted
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def main(argv):
|
|
257
|
+
repo = Path(__file__).resolve().parents[1]
|
|
258
|
+
if argv[:1] == ['--check']:
|
|
259
|
+
drifted = check(repo)
|
|
260
|
+
for rel in drifted:
|
|
261
|
+
kind = next(k for k, r in TARGETS.items() if r == rel)
|
|
262
|
+
current = (repo / rel).read_text() if (repo / rel).exists() else ''
|
|
263
|
+
sys.stdout.writelines(difflib.unified_diff(current.splitlines(True), _splice(current, kind).splitlines(True), rel, rel + ' (generated)'))
|
|
264
|
+
return 1 if drifted else 0
|
|
265
|
+
if argv[:1] == ['--write']:
|
|
266
|
+
for kind, rel in TARGETS.items():
|
|
267
|
+
path = repo / rel
|
|
268
|
+
current = path.read_text() if path.exists() else ''
|
|
269
|
+
path.write_text(_splice(current, kind))
|
|
270
|
+
return 0
|
|
271
|
+
kind = argv[0].lstrip('-') if argv else ''
|
|
272
|
+
if kind not in RENDERERS:
|
|
273
|
+
sys.exit('usage: tokens.py --kdl|--css|--sh|--md|--check|--write')
|
|
274
|
+
sys.stdout.write(render(kind))
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
if __name__ == '__main__':
|
|
279
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Lanes 16-colour SGR literals for view.sh status screens. Sourced, never executed.
|
|
2
|
+
# The block below is generated: edit libexec/tokens.py and run `python3 libexec/tokens.py --write`.
|
|
3
|
+
# BEGIN lanes tokens (generated by libexec/tokens.py --sh; do not edit by hand)
|
|
4
|
+
LZ_BOLD='\033[1m'
|
|
5
|
+
LZ_DIM='\033[90m'
|
|
6
|
+
LZ_URGENT='\033[31m'
|
|
7
|
+
LZ_ATTENTION='\033[33m'
|
|
8
|
+
LZ_WORKING='\033[34m'
|
|
9
|
+
LZ_DONE='\033[32m'
|
|
10
|
+
LZ_RESET='\033[0m'
|
|
11
|
+
# END lanes tokens
|
|
@@ -103,7 +103,7 @@ while :; do
|
|
|
103
103
|
# A background-created zellij session has no real size yet: idle until a client has attached once.
|
|
104
104
|
while [ ! -e "$state/clients/$device.ready" ] || { [ "$mobile" = 1 ] && [ ! -e "$dir/$ws.$slot.shown" ]; }; do
|
|
105
105
|
status_screen "$ws" 'waiting for the first client…' ''
|
|
106
|
-
run
|
|
106
|
+
run python3 "$repo/libexec/connection.py" --title "${title:-$ws}" --wait 2 || :
|
|
107
107
|
done
|
|
108
108
|
|
|
109
109
|
# zellij sizes a tab only once some client has looked at it; until then the pane has a made-up size
|
|
@@ -115,7 +115,7 @@ while :; do
|
|
|
115
115
|
[ "$mobile" != 1 ] || { min_cols=20; min_rows=4; }
|
|
116
116
|
if [ "${cols:-0}" -ge "$min_cols" ] 2>/dev/null && [ "${rows:-0}" -ge "$min_rows" ] 2>/dev/null; then break; fi
|
|
117
117
|
status_screen "$ws" 'waiting to be shown…' ''
|
|
118
|
-
sizing=1; run
|
|
118
|
+
sizing=1; run python3 "$repo/libexec/connection.py" --title "${title:-$ws}" --wait 3 || :; sizing=0
|
|
119
119
|
done
|
|
120
120
|
|
|
121
121
|
lane=$(head -n 1 "$target_file" 2>/dev/null || :)
|
|
@@ -126,13 +126,13 @@ while :; do
|
|
|
126
126
|
if [ "$slot" = main ]; then hint='Pick one in the sidebar (Ctrl-Alt-/ to find a lane).'
|
|
127
127
|
else hint='In the sidebar, move to a lane and press 2 (or 3) to show it here.'; fi
|
|
128
128
|
status_screen "$ws" 'No lane selected.' "$hint"
|
|
129
|
-
switching=0; run
|
|
129
|
+
switching=0; run python3 "$repo/libexec/connection.py" --title "${title:-$ws}" --wait 5 --preserve-screen || :; continue ;;
|
|
130
130
|
esac
|
|
131
131
|
resolve=$state/resolve/$lane
|
|
132
132
|
if [ ! -r "$resolve" ]; then resolve=$state/resolve.dynamic/$lane; fi
|
|
133
133
|
if [ ! -r "$resolve" ]; then
|
|
134
134
|
status_screen "$lane" 'This lane is no longer in the registry.' 'Pick another lane in the sidebar.'
|
|
135
|
-
switching=0; run
|
|
135
|
+
switching=0; run python3 "$repo/libexec/connection.py" --title "${title:-$ws}" --wait 5 --preserve-screen || :; continue
|
|
136
136
|
fi
|
|
137
137
|
{ IFS= read -r holder; IFS= read -r f2; IFS= read -r f3; IFS= read -r f4; IFS= read -r title; } < "$resolve"
|
|
138
138
|
|
|
@@ -140,9 +140,10 @@ while :; do
|
|
|
140
140
|
switching=0
|
|
141
141
|
started=$(date +%s)
|
|
142
142
|
case "$holder" in
|
|
143
|
-
tmux) run "$repo/holders/tmux.sh" "$f2" "$f3" "$f4" || : ;;
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
tmux) run python3 "$repo/libexec/connection.py" --title "${title:-$lane}" -- "$repo/holders/tmux.sh" "$f2" "$f3" "$f4" || : ;;
|
|
144
|
+
ssh) run "$repo/holders/ssh.sh" "$f2" "$f3" "$f4" || : ;;
|
|
145
|
+
zmx) run python3 "$repo/libexec/connection.py" --title "${title:-$lane}" -- "$repo/holders/zmx.sh" "$f2" "$f3" "$f4" || : ;;
|
|
146
|
+
exec) run python3 "$repo/libexec/connection.py" --title "${title:-$lane}" -- "$repo/holders/exec.sh" "$f2" "$f3" "$f4" || : ;;
|
|
146
147
|
*) rc=64 ;;
|
|
147
148
|
esac
|
|
148
149
|
if [ "$switching" = 1 ]; then delay=1; continue; fi
|
|
@@ -173,7 +174,7 @@ while :; do
|
|
|
173
174
|
health_hint "$ended"
|
|
174
175
|
status_screen "${title:-$lane}" "${reason:-viewer detached (exit $rc)}${untouched}; retrying in ${delay}s.${host_hint:+
|
|
175
176
|
$host_hint}" "$holder · $f2 · $f3 · $f4" "$lead"
|
|
176
|
-
run
|
|
177
|
+
run python3 "$repo/libexec/connection.py" --title "${title:-$ws}" --wait "$delay" --preserve-screen || :
|
|
177
178
|
delay=$((delay * 2))
|
|
178
179
|
if [ "$delay" -gt 15 ]; then delay=15; fi
|
|
179
180
|
done
|
|
@@ -5,6 +5,21 @@ The Lanes shell enhances the pinned Zellij 0.45.1 web client on phones. It uses
|
|
|
5
5
|
There is no separate auth system or command API. Workspace names are rendered as text, and navigation
|
|
6
6
|
uses Zellij's authenticated `FocusPane` messages. Desktop browsers keep the upstream interface.
|
|
7
7
|
|
|
8
|
+
`laneShellMode` (in `mobile.js`) chooses the mode once at load and records it on `<html data-lanes-shell>`:
|
|
9
|
+
phones get this shell; tablets (other touch screens) get the same shell with the dock in the top bar and the
|
|
10
|
+
workspace sheet as a left panel, and set `zellij:mobile-ui` to `on` when it is unset (iPad Safari reports a desktop
|
|
11
|
+
user agent). Desktop browsers (fine pointer) get only a **Lanes** chip and an overlay bar with workspace and lane
|
|
12
|
+
pickers (`laneBarModel`, from the `MobileState` sent to every web client). The laptop/tablet code and styles live in
|
|
13
|
+
`browser.js`/`browser.css`, which `mobile.js` appends only on a tablet or desktop (a phone never downloads them), so
|
|
14
|
+
the phone's `mobile.js`/`mobile.css` budget (spec §8) stays with the phone. The chip cancels `mousedown` and
|
|
15
|
+
refocuses the terminal, so the keys stay the terminal's. On a tablet, `browser.js` also undoes the client's per-load single-pane start once
|
|
16
|
+
(`laneFullRender`, the client's own `SetMobileRenderPreferences {single_pane: false, fit: true}`), so a desktop
|
|
17
|
+
view's sidebar shows beside its lanes. `tests/web/activation.test.mjs` (`node --test`) pins the activation matrix,
|
|
18
|
+
the picker model and that render preference; `tests/unit/test_web_view.py` pins the size budgets and runs that
|
|
19
|
+
suite when node is installed. After an upgrade,
|
|
20
|
+
check that `__zjMobileUi`, `__zjSendControl`, `__zjSoftKbdCapture`, `__zjSyncInactiveCursorStyle` and `window.term`
|
|
21
|
+
still exist in the served `app.js`.
|
|
22
|
+
|
|
8
23
|
The phone keyboard opens through a native tap on an input field. The bottom **Type here** field contains
|
|
9
24
|
Zellij's original capture input in its closed shadow root, preserving its keyboard/IME handling. The client
|
|
10
25
|
blocks programmatic focus on text editors and keeps `__zjSoftKbdEnabled` false, so upstream touch handlers,
|