@vibelord/lanes 1.1.1 → 1.2.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.
Files changed (59) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +45 -22
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2459 -164
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +106 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1091 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1140 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +618 -13
  20. package/payload/lanes/docs/DESIGN.md +122 -44
  21. package/payload/lanes/docs/INSTALL.md +157 -17
  22. package/payload/lanes/docs/RUNBOOK.md +603 -72
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +777 -136
  25. package/payload/lanes/install/enable-web.sh +2 -0
  26. package/payload/lanes/install/host-install.sh +163 -7
  27. package/payload/lanes/install/services.py +148 -18
  28. package/payload/lanes/install.sh +68 -7
  29. package/payload/lanes/libexec/agent_state.py +74 -1
  30. package/payload/lanes/libexec/cursor_sync.py +6 -0
  31. package/payload/lanes/libexec/device_sync.py +47 -10
  32. package/payload/lanes/libexec/economy_poll.py +1 -0
  33. package/payload/lanes/libexec/enrold.py +41 -22
  34. package/payload/lanes/libexec/keys.py +54 -23
  35. package/payload/lanes/libexec/lanes_lib.py +620 -22
  36. package/payload/lanes/libexec/members.py +16 -2
  37. package/payload/lanes/libexec/notify.py +104 -24
  38. package/payload/lanes/libexec/registrations.py +216 -18
  39. package/payload/lanes/libexec/session_actions.py +182 -14
  40. package/payload/lanes/libexec/sidebar +11 -3
  41. package/payload/lanes/libexec/sidebar.py +2135 -361
  42. package/payload/lanes/libexec/statusd.py +137 -10
  43. package/payload/lanes/libexec/studio_host.py +8 -1
  44. package/payload/lanes/libexec/tokens.py +121 -30
  45. package/payload/lanes/libexec/tokens.sh +86 -2
  46. package/payload/lanes/libexec/update.py +972 -0
  47. package/payload/lanes/libexec/users.py +614 -0
  48. package/payload/lanes/libexec/view.sh +34 -6
  49. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  50. package/payload/lanes/systemd/lanes-gc.service +1 -1
  51. package/payload/lanes/systemd/lanes-update.service +17 -0
  52. package/payload/lanes/systemd/lanes-update.timer +17 -0
  53. package/payload/lanes/web/README.md +83 -24
  54. package/payload/lanes/web/browser.js +3 -5
  55. package/payload/lanes/web/mobile.css +17 -17
  56. package/payload/lanes/web/mobile.js +44 -32
  57. package/payload/lanes/workspaces.example/README.md +3 -1
  58. package/payload/lanes/zellij/config.kdl +106 -56
  59. package/payload/manifest.json +5 -3
@@ -5,12 +5,43 @@ Shows one workspace's lanes with color, icon and live status. Selecting a lane w
5
5
  target file and signals the viewport supervisor; the zellij layout is never touched.
6
6
  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
- 2 / 3 open the lane under the cursor in the 2nd / 3rd viewport (wide view sessions) · x / × close (asks y/n)
8
+ 2 / 3 open the lane under the cursor in the 2nd / 3rd viewport (wide view sessions) · x / × close (y, then Enter)
9
+ u unregister a session added with a (z within 10 s undoes it; a declared lane says which file to edit)
9
10
  ? shows every key in the body until the next key · Tab / Shift-Tab open the next / previous lane that needs you
10
- Attention first: lanes whose agent needs you, is working, or finished unseen sort into sections above the rest; the
11
- agent glyph sits in column 2, and every row carries an age (agent `since`, else last output). The cursor is a LANE:
12
- when a lane changes section the cursor goes with it. The pane title (OSC 2) carries the header counts, and a lane that
11
+ The keyboard goes back to the lane after every action (cockpit v3 S3): the sidebar and the lane share one keyboard, so
12
+ an action that ends — a close, a rename, a register, a cancel, Esc, r, closing the key sheet, a click that opened
13
+ nothing — moves the focus to this pane's viewport once, and the rest of that read was typed for the lane (dropped).
14
+ A prompt, the filter and the armed upload strip keep it; the wheel and a mouse release never move it.
15
+ While the keyboard is here the footer legend says so (cockpit v3 S4): ` ▌ keys go to sidebar · Esc → lane`, from
16
+ Zellij's focus reports (`?1004h`), else for 3 s after a key. Ctrl-Alt-/ and Ctrl-Alt-u (from anywhere in the tab)
17
+ type nothing: they move the focus here and `lanes sidebar-key` leaves views/<device>/<ws>.sidebar.cmd, which starts
18
+ the filter / arms the upload strip (reload()'s stamp, and before the next read of keys). Ctrl-Alt-l only focuses.
19
+ One prompt at a time (cockpit v3 S6): `self.prompt = {verb, entry, stage, value, armed}` — a name field (` › Rename:
20
+ value█`) or a confirmation (` › close ‘sh’ — y⏎ confirm · Esc cancel`) — and ONE footer_text() for the desktop and
21
+ the phone. No prompt outlives the keyboard (S6 fix cycle 1): a click on the list ends it before it acts, a name field
22
+ types nothing for an arrow / Tab / Shift-Tab / PageUp / PageDown, and hand_off() closes one still open.
23
+ What an action says in the footer lasts 3 s, or until the next action begins; never just until a key.
24
+ Attention first: lanes whose agent needs you, is working, or finished unseen sort into sections above the rest, each in
25
+ the order its lanes entered it (statusd's `stable_since`); the agent glyph sits in column 2, and every row carries an
26
+ age (agent `since`, else last output). The cursor is a LANE: when a lane changes section the cursor goes with it and
27
+ the list itself does not scroll. The pane title (OSC 2) carries the header counts, and a lane that
13
28
  starts needing you raises one desktop notification (OSC 9), both inside the frame's one write — no fork.
29
+ Per person (cockpit v3 R3): on a device with an owner the look and the opened-lane marks are that person's, shared by
30
+ every device they own through users/<login>.json — the profile's mtime is the stamp's seventh field, so a Done lane
31
+ opened on the phone leaves Done here, and a look recorded there repaints here, on the next tick. Only the look keys a
32
+ device file marks as its own (`--device-only`, its `device_only` list) win for that device; a key from before the
33
+ profile paints only where the profile sets none. A device nobody owns never touches a profile.
34
+ Pins and hidden lanes (cockpit v3 R5): the profile's `lanes{pins, hidden}`. A hidden registry lane leaves the list, the
35
+ header counts and the notifications (never a divider, never a session listed under Unregistered: nothing alive is
36
+ ever hidden), until `o` shows the hidden ones again, faint; a pinned lane leads its section with the ▲ mark and the body
37
+ never lets it fall below `⋮ N more`. `p` pins / unpins, `h` hides (or shows again), `H` hides this workspace (y, then
38
+ Enter: `lanes upgrade-session` closes its tab on every device of the person), `o` toggles the hidden lanes.
39
+ Which lanes (cockpit v3 V2): the `?` sheet's first row is ` lanes <installed> · view <this tree>`; while an update has
40
+ flipped `current` to another tree than the one this sidebar runs, the footer's rung below the health banner says
41
+ ` server 1.2.0 · view 1.1.0 — press ?`. `current/VERSION` is read at start and every 30 s inside reload(), never per tick.
42
+ Self-swap (cockpit v3 U5): a sidebar started through `current` that finds the installed version changed swaps onto it
43
+ as soon as it is idle — it saves views/<device>/<ws>.<slot>.sidebar.state {cursor_id, top, seen, filter} and exits 75;
44
+ the wrapper re-executes itself from `current` at once and the new sidebar takes the state (a ~100 ms repaint).
14
45
  Upload strip (one row above the footer): click it or press U (Ctrl-Alt-u from anywhere), then DROP FILES anywhere
15
46
  on the window. A terminal delivers a drop as a paste of the LOCAL path into the focused pane, so the strip must have
16
47
  focus first; the file itself is then sent by the upload helper on your machine (it only reads folders you allowed
@@ -19,10 +50,13 @@ there). Below it, when there is room, the strip row: the fleet economy line from
19
50
  one tail cell by priority: stale `?` > starving ⚠ > downgraded ↓ > mostly paid `$`.
20
51
  Truthful under load: a lane statusd could not observe keeps its last glyph, dimmed, with a `?`; the footer line warns
21
52
  when the host is under pressure or statusd has fallen behind (age of $STATE/health, measured with our own clock).
53
+ Never frozen by an action (cockpit v3 S9): a close, `P`'s write and a new shell run on one worker thread (Worker); the
54
+ row they act on carries the dim `pending` mark and takes no key until run()'s tick takes the answer (finish_actions).
22
55
  Run via libexec/sidebar (a loop), so it never leaves its pane empty.
23
56
  """
24
57
  import json
25
58
  import os
59
+ import queue
26
60
  import re
27
61
  import select
28
62
  import shlex
@@ -30,6 +64,7 @@ import signal
30
64
  import subprocess
31
65
  import sys
32
66
  import termios
67
+ import threading
33
68
  import time
34
69
  from operator import itemgetter
35
70
  from pathlib import Path
@@ -38,9 +73,11 @@ import unicodedata
38
73
 
39
74
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
40
75
  import lanes_lib as L # noqa: E402
76
+ import agent_state as AS # noqa: E402
41
77
  import session_actions as A # noqa: E402
42
78
  import registrations as R # noqa: E402
43
79
  import tokens # noqa: E402
80
+ import users # noqa: E402
44
81
 
45
82
  STATE = os.environ.get('LANES_STATE') or os.path.expanduser('~/.local/state/lanes')
46
83
  # What the harness in a lane is doing (statusd's additive `agent{}`, Task 4/5 vocabulary): `key -> (unicode, ascii,
@@ -51,8 +88,23 @@ AGENT_GLYPHS = tokens.AGENT_GLYPHS
51
88
  ATTENTION = (('needs', 'Needs you', 'needs_approval', ('needs_approval', 'errored', 'needs_input')),
52
89
  ('working', 'Working', 'working', ('working',)),
53
90
  ('done', 'Done', 'done', ('done',)))
91
+ # The section headers drawn even while empty (`── ▸ Working (0)`), once the workspace has an agent (cockpit v3 S2): a
92
+ # lane entering or leaving one of them then never adds or removes a header row above the lanes below it. Done is drawn
93
+ # only with a member — it sits below both, so its header coming and going moves no attention row.
94
+ ALWAYS_DRAWN = ('needs', 'working')
95
+ # How the sidebar spends palette v2 (cockpit v3 P2, LNS-70). In the VIVID modes — truecolor and the 256 set, colours
96
+ # Lanes defines — a section's words take the section's tone (its count stays ink-dim), lane names are ink (an identity
97
+ # colour still wins), the age is ink-dim only outside the attention sections, the stream glyph keeps its tone beside an
98
+ # agent (ink-dim only when stale), and the cursor row lies on the `selected` band. 16 colours are the user's own
99
+ # palette, whose slot 3 may be a yellow no light background carries: they keep v1's restraint byte for byte, and
100
+ # `none` has no colour to spend. The section tone is the one the Needs you age has always had (spec §2.3): attention.
101
+ COLOR_MODES = ('truecolor', '256', '16', 'none')
102
+ VIVID = tokens.VIVID # truecolor, 256 — one definition, shared with view.sh's LZ_VIVID (cockpit v3 P3)
103
+ SECTION_TONE = {'needs': 'tone-attention', 'working': 'tone-working', 'done': 'tone-done'}
54
104
  HEADER_COUNTS = ('needs_approval', 'needs_input', 'working', 'done') # the header's glyph counts (spec §4.3)
55
- SECTION_OF = {status: key for key, _title, _glyph, members in ATTENTION for status in members}
105
+ # status -> section key (None = the rest): statusd's table (`agent_state.SECTION`, with `RANK` and the `settle` debounce
106
+ # that publishes the statuses this reads, cockpit v3 S1), never a second copy. ATTENTION above draws it in RANK order.
107
+ SECTION_OF = AS.SECTION
56
108
  NEEDS_RANK = {status: rank for rank, status in enumerate(ATTENTION[0][3])}
57
109
  INF = float('inf')
58
110
  # Tones by role name. Each Sidebar resolves its own table per device (`apply_look`, spec §2.4); this 16-colour table
@@ -70,6 +122,75 @@ NEEDS_YOU = ATTENTION[0][3]
70
122
  OSC_UNSAFE = re.compile(r'[\x00-\x1f\x7f-\x9f]')
71
123
  OSC_TEXT_MAX = 200 # characters of one title / notification payload
72
124
  OSC11_PREFIX = re.compile(r'\033\](?:1(?:1(?:;(?:r(?:g(?:b(?::[0-9A-Fa-f/]{0,14})?)?)?)?)?)?)?(?:\033)?$')
125
+ # Focus reports (cockpit v3 S4): start_bytes() asks for them (`?1004h`) and Zellij 0.45.1 forwards them to the pane —
126
+ # `ESC [ I` when this pane gains the focus, `ESC [ O` when it loses it (measured 2026-10-06). handle() takes them out
127
+ # of every read before the keys: a report is never a key.
128
+ FOCUS_REPORT = re.compile(r'\033\[([IO])')
129
+ # While Zellij has sent no report, the footer says the keyboard is here for this long after a key (a key reaches only
130
+ # the focused pane); once one has arrived the reports decide.
131
+ FOCUS_LEGEND_S = 3.0
132
+ # A footer message (` Closed sh`, ` Cancelled`, why a close failed) lasts this long, measured on run()'s 1 s tick
133
+ # (expire_message), or until the next action begins (begin_action) — never just until the next key (cockpit v3 S6).
134
+ MESSAGE_S = 3.0
135
+ # `u` on a session added with `a` unregisters it at once (cockpit v3 S7): the row moves to Unregistered, the footer
136
+ # offers ` Unregistered ‘name’ · z undo`, and `z` registers it again for this long after (expire_undo, on the 1 s tick).
137
+ UNDO_S = 10.0
138
+ # `P` (cockpit v3 S8) writes terminals.json and starts `lanes sync`; the row stays listed with the `pending` mark until
139
+ # the registry lists its lane (settle_pending). One the registry has not listed PENDING_S after `P`, once the sync the
140
+ # sidebar started has ended, lets go (expire_pending, on the 1 s tick). PENDING_S is the latest a sync can still list
141
+ # it, from the bounds where they are enforced (S8 fix cycle 1, DEF-1762; it was 30 s, and a slow docker daemon made a
142
+ # sync on its way look like none): the sidebar's own sync can find the lock held — the 2-minute timer's sync, on a
143
+ # slow docker — and end having published nothing; statusd then starts one on the changed terminals.json at its first
144
+ # post-cycle check the hot-sync rate limit admits — at most HOT_SYNC_EVERY_S, its slowest pause (INTERVAL 3 s ×
145
+ # SLOWDOWN['critical'] 4) and one cycle, which can hold a docker inspect and a probe round (PROBE_TIMEOUT 5 s +
146
+ # PROBE_GRACE 2 s) — and that sync waits for the lock and for docker inspect again. A sync the sidebar started that
147
+ # is STILL running past PENDING_S holds the row too: it is a sync on its way.
148
+ SYNC_S = L.REGISTRY_LOCK_S + L.DOCKER_INSPECT_S # 25 s: one `lanes sync`, the lock + docker inspect
149
+ HOT_SYNC_START_S = 10 + 3 * 4 + L.DOCKER_INSPECT_S + 5 + 2 # 49 s: statusd starts it (statusd's constants)
150
+ PENDING_S = HOT_SYNC_START_S + SYNC_S + 6.0 # 80 s: + the files a sync reads and publishes
151
+ # The slow verbs run on the sidebar's one worker thread (cockpit v3 S9, LNS-67; Worker): until S9 each ran inside the
152
+ # key's handle() and the frame froze for as long as it took — a close waits up to REGISTRY_LOCK_S for the registry lock
153
+ # (statusd's hot sync, the timer's sync, another device) and then up to three tmux answers of 5 s each, a new shell up
154
+ # to 15 s for `docker exec` (plan context item 5, DEF-1763). The row an action is on carries the `pending` mark and
155
+ # refuses every verb until run()'s tick takes the answer (finish_actions); the word that refusal says, by verb:
156
+ PENDING_WORDS = {'close': 'closing', 'promote': 'registering', 'new-shell': 'creating'}
157
+ LANES_BIN = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'bin', 'lanes')
158
+ # The tree this sidebar runs (cockpit v3 V2, LNS-84) is lanes_lib.REPO, resolved — an npm install's
159
+ # ~/.local/share/lanes/pkg/<version>/, whatever `current` pointed at when the process started; the installed release is
160
+ # read through lanes_lib.installed_tree() (the `current` link, whatever it names now) with lanes_lib.tree_version (W9
161
+ # U0's helpers: one reader of a tree's release for the CLI, doctor and the sidebar). The 30 s is how stale the footer's
162
+ # version note may be (plan V2: never per tick).
163
+ VERSION_RECHECK_S = 30.0
164
+ # The tree this process was STARTED through, unresolved (cockpit v3 U5, LNS-93): the wrapper runs "$here/sidebar.py"
165
+ # with its logical directory, so a layout's `…/current/libexec/sidebar` gives `…/current` here. Its realpath differs
166
+ # from lanes_lib.REPO once an update has flipped `current`: only then would the wrapper's re-exec run another tree, and
167
+ # only then does the sidebar exit SWAP_EXIT (a `pkg/<version>` start would re-exec the same tree for ever).
168
+ LAUNCH_TREE = Path(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
169
+ SWAP_EXIT = 75 # EX_TEMPFAIL: libexec/sidebar re-executes itself at once, no pause
170
+ STATE_FRESH_S = 60.0 # a .sidebar.state older than this (by its own `at`) is no swap's: dropped unread
171
+ # The one prompt (cockpit v3 S6): `self.prompt = {verb, entry, stage, value, armed}`. A `name` stage is a text field
172
+ # (` › Rename: value█`); a `confirm` stage is ` › <word> ‘name’ — y⏎ confirm · Esc cancel`. The verb is what the phone
173
+ # title announces (DM-LANES-SIDEBAR-TITLE); the word is what the footer says it does — `P` registers the session.
174
+ PROMPT_FIELDS = {'rename': 'Rename', 'promote': 'Register as'}
175
+ PROMPT_WORDS = {'close': 'close', 'promote': 'register', 'hide': 'hide'}
176
+ # The owner's profile (cockpit v3 R3, LNS-76): users/<login>.json (libexec/users.py) of the person this device belongs
177
+ # to. Opening a lane writes its mark there through users.update, which holds the profile's lock; the CLI waits up to
178
+ # users.LOCK_S (1 s) for it, a sidebar only PROFILE_LOCK_S — every writer holds it for milliseconds (read, merge, one
179
+ # atomic write), so a wait this long means a stuck writer, and the keyboard must not stall behind one. A write that
180
+ # did not get the lock is DROPPED (the mark is already this device's: in memory and in clients/<device>.seen.json) and
181
+ # written again by run()'s tick every PROFILE_RETRY_S until the profile takes it (poll_profile): at most 0.2 s of
182
+ # blocking per 5 s while a writer is stuck, none at all otherwise.
183
+ PROFILE_LOCK_S = 0.2
184
+ PROFILE_RETRY_S = 5.0
185
+ # 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
+ NO_PROFILE = 'no owner: lanes device claim'
187
+
188
+
189
+ def shown_version(path):
190
+ """A tree's release as the sidebar paints it (cockpit v3 V2, LNS-84): lanes_lib.tree_version's first line, control
191
+ characters dropped (a VERSION file never writes an escape into the terminal), at most 32 characters; '' when there
192
+ is none to read."""
193
+ return OSC_UNSAFE.sub('', (L.tree_version(path) or '').split('\n')[0])[:32]
73
194
 
74
195
 
75
196
  def age3(seconds):
@@ -168,18 +289,24 @@ MOBILE_LABELS = {'active': 'Active', 'quiet': 'Quiet', 'missing': 'Container off
168
289
 
169
290
  def resolve_modes(env, meta):
170
291
  """(color, theme, glyphs) from the device file first, then the environment, then the defaults of spec §2.4.
171
- Pure; an unknown value falls back to the default rather than raising (a hand-edited file must not kill a pane)."""
292
+ Pure; an unknown value falls back to the default rather than raising (a hand-edited file must not kill a pane).
293
+ Colour (cockpit v3 P2): truecolor when COLORTERM says `truecolor`/`24bit`, else 256 when TERM says `256color`
294
+ (xterm-256color, screen-256color, tmux-256color), else 16."""
172
295
  meta = meta if isinstance(meta, dict) else {}
173
296
  if env.get('NO_COLOR'):
174
297
  color = 'none'
298
+ elif env.get('LANES_COLOR'):
299
+ color = env['LANES_COLOR']
300
+ elif env.get('COLORTERM') in ('truecolor', '24bit'):
301
+ color = 'truecolor'
175
302
  else:
176
- color = env.get('LANES_COLOR') or ('truecolor' if env.get('COLORTERM') in ('truecolor', '24bit') else '16')
303
+ color = '256' if '256color' in (env.get('TERM') or '') else '16'
177
304
  color = meta.get('color') or color
178
305
  theme = meta.get('theme') or env.get('LANES_THEME') or 'auto'
179
306
  locale = [env.get(k) or '' for k in ('LC_ALL', 'LC_CTYPE', 'LANG')]
180
307
  utf8 = any('UTF-8' in value.upper() or 'UTF8' in value.upper() for value in locale)
181
308
  glyphs = meta.get('glyphs') or env.get('LANES_GLYPHS') or ('unicode' if utf8 or not any(locale) else 'ascii')
182
- if color not in ('truecolor', '16', 'none'):
309
+ if color not in COLOR_MODES:
183
310
  color = '16'
184
311
  if theme not in ('dark', 'light', 'auto'):
185
312
  theme = 'auto'
@@ -188,6 +315,44 @@ def resolve_modes(env, meta):
188
315
  return color, theme, glyphs
189
316
 
190
317
 
318
+ def layered_look(profile_look, meta):
319
+ """devices/<device>.json as resolve_modes and resolve_dim_notify read it on a device with an owner (cockpit v3 R3,
320
+ LNS-76): the person's profile look over the device file's, except where the device keeps a key for itself. Per key
321
+ of `theme`, `color` and `glyphs`: the device file's value when its `device_only` list names that key and the value
322
+ is valid (`lanes attach --device-only`: users.device_only), else the profile's, else whatever the device file holds
323
+ — an unmarked key from before the profile (a look recorded before the device had an owner, or by a release before
324
+ R2; `lanes user show` names it and `lanes device claim` folds it into the profile), so it paints only where the
325
+ profile sets none (R3 review fix, DEF-1782: it used to win over the profile for good). Every other key (`mobile`,
326
+ `notify`, `dim`, …) is the device file's alone. resolve_modes then puts the environment and the defaults below:
327
+ device (--device-only) → profile → device (from before) → environment → defaults. Pure. A device nobody owns
328
+ never comes here (apply_look gets its file unchanged)."""
329
+ layered = dict(meta) if isinstance(meta, dict) else {}
330
+ own = users.device_only(layered)
331
+ for key, allowed in users.LOOK.items():
332
+ value = (profile_look or {}).get(key)
333
+ if key not in own and value in allowed:
334
+ layered[key] = value
335
+ return layered
336
+
337
+
338
+ # The two switches beside the look (cockpit v3 P4, LNS-72, DEF-1577), in the variables' own words: `dim` is the SGR of
339
+ # the 16-colour dim (90, or 2 for a palette whose colour 8 is near the background), `notify` 0 silences OSC 9.
340
+ DIM_VALUES, NOTIFY_VALUES = ('90', '2'), ('1', '0')
341
+ DIM16 = {'90': '\033[90m', '2': '\033[2m'}
342
+
343
+
344
+ def resolve_dim_notify(env, meta):
345
+ """(dim, notify) from the device file's `dim`/`notify` (`lanes attach DEVICE --dim D --notify N`), then LANES_DIM /
346
+ LANES_NOTIFY (which reach a sidebar through the view server's environment, bin/lanes clean_env), then `90` / `1`.
347
+ Pure; a value outside the vocabulary is skipped, layer by layer, so the environment keeps its old rule — only `2`
348
+ dims with SGR 2, only `0` silences — and a hand-edited device file never kills a pane."""
349
+ meta = meta if isinstance(meta, dict) else {}
350
+ picked = []
351
+ for key, variable, values in (('dim', 'LANES_DIM', DIM_VALUES), ('notify', 'LANES_NOTIFY', NOTIFY_VALUES)):
352
+ picked.append(next((value for value in (meta.get(key), env.get(variable)) if value in values), values[0]))
353
+ return tuple(picked)
354
+
355
+
191
356
  def parse_osc11(body):
192
357
  """'11;rgb:RRRR/GGGG/BBBB' (1- to 4-digit hex components) -> 'dark'|'light' by relative luminance (< 0.5 dark);
193
358
  anything else -> None."""
@@ -275,18 +440,38 @@ def health_banner(health, written, now, warning='⚠', sep=' · '):
275
440
 
276
441
 
277
442
  SLOTS = ['main', 'b', 'c']
443
+ # The target that points a viewport at nothing (cockpit v3 S9 fix cycle 1, DEF-1767): view.sh reads a target outside the
444
+ # lane-id grammar as `No lane selected`, and that screen's wait (connection.py wait_screen) reads keys and discards them.
445
+ # session_actions.clear_viewers writes the same target into every view of a session it has killed; close_lane writes it
446
+ # FIRST into this view's own viewports, before the keyboard moves (empty_views).
447
+ EMPTY_VIEW = '!empty'
278
448
  BOX_ROWS = 1 # the upload strip (spec §4.6(d)); it was a four-row box
279
- # The `?` key sheet (spec §4.7): (key, its seven-bit twin, action), one line per key handle() accepts. A line is
280
- # ` {key:<10}{action}`: the key field is 10 cells and an action at most 23, so a line is at most 35 cells — the
281
- # sidebar's row width with column 36 never written. `Tab`/`Shift-Tab` are Task 25's keys.
449
+ # The `?` key sheet (spec §4.7): (key, its seven-bit twin, action). A line is ` {key:<9} {action}`: the key field is 9
450
+ # cells and a blank (10 cells, as `{key:<10}` was, for every key of at most 9 cells; the 10-cell `Ctrl-Alt-l` keeps a
451
+ # blank before its action) and an action at most 23 cells (22 after a 10-cell key), so a line is at most 35 cells, the
452
+ # sidebar's row width with column 36 never written. The sheet is the SHORT form of the user guide's "Sidebar keys"
453
+ # table (cockpit v3 S10, LNS-68): companion keys share a line (`J K`, `Tab S-Tab`, `+ U`, `r ?`), PageUp / PageDown
454
+ # are only in the table, and the last line is the one chord that brings the keyboard here from a lane (`Ctrl-Alt-l`,
455
+ # config.kdl's shared block; lane chords such as Ctrl-Alt-m are no sidebar keys and stay off). 18 lines below the
456
+ # dynamic version row (cockpit v3 V2, LNS-84: ` lanes <current> · view <tree>`, key_sheet) — exactly the 19-row body
457
+ # 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.
459
+ # test_sidebar.DocumentedKeys holds the sheet, the table and handle() to the same keys.
282
460
  KEY_SHEET = (('↑ ↓ j k', 'Up Dn j k', 'move'), ('⏎ → Space', 'Enter Spc', 'open the cursor lane'),
283
- ('J K', 'J K', 'open next / prev lane'), ('Tab', 'Tab', 'next lane needing you'),
284
- ('Shift-Tab', 'Shift-Tab', 'prev lane needing you'), ('2 3', '2 3', 'show in viewport 2 / 3'),
285
- ('/', '/', 'find a lane'), ('Esc', 'Esc', 'clear / cancel'), ('g G', 'g G', 'first / last'),
286
- ('+', '+', 'new shell'), ('a', 'a', 'add to a workspace'), ('x', 'x', 'close (asks y/n)'),
287
- ('R', 'R', 'rename'), ('P', 'P', 'register a session'), ('U', 'U', 'arm the upload strip'),
288
- ('r', 'r', 'reload'), ('?', '?', 'this sheet'))
461
+ ('J K', 'J K', 'open next / prev lane'), ('Tab S-Tab', 'Tab S-Tab', 'next / prev needing you'),
462
+ ('2 3', '2 3', 'show in viewport 2 / 3'), ('/', '/', 'find a lane'),
463
+ ('Esc', 'Esc', 'cancel, back to lane'), ('g G', 'g G', 'first / last'),
464
+ ('+ U', '+ U', 'new shell / upload'), ('a', 'a', 'add to a workspace'),
465
+ ('x', 'x', 'close (y, then Enter)'), ('u z', 'u z', 'unregister / undo 10 s'),
466
+ ('R', 'R', 'rename'), ('P', 'P', 'register a session'), ('p h', 'p h', 'pin / hide lane'),
467
+ ('H o', 'H o', 'hide tab / hidden lanes'), ('r ?', 'r ?', 'reload / this sheet'),
468
+ ('Ctrl-Alt-l', 'Ctrl-Alt-l', 'focus the sidebar'))
289
469
  PASTE_START, PASTE_END = '\033[200~', '\033[201~'
470
+ # A paste typed into a footer field (Sidebar.pasted): a run of line breaks / tabs is one space, never an Enter.
471
+ PASTE_BREAKS = re.compile(r'[\t\n\v\f\r]+')
472
+ # W9 U6 (LNS-94): how often the sidebar looks for clients/<device>.update-ready — one stat per UPDATE_POLL seconds,
473
+ # never one per tick (spec §8's tick budget counts reload/poll_health/poll_upload; this look is outside the tick).
474
+ UPDATE_POLL = 30
290
475
 
291
476
 
292
477
  def key_length(data, i):
@@ -308,19 +493,121 @@ def key_length(data, i):
308
493
  return len(data) - i if end < 0 else end + 2
309
494
 
310
495
 
496
+ def moves_focus(key):
497
+ """Whether ONE key (as key_length() cuts it) proves this pane has the keyboard, so an action it ends may hand the
498
+ keyboard back (cockpit v3 S3): a typed key and a mouse PRESS do — zellij delivers keys only to the focused pane,
499
+ and a click focuses the pane it lands on. A wheel tick, motion, a mouse release and an incomplete report do not:
500
+ zellij delivers those to the pane under the pointer, focused or not, and focus_lane()'s `move-focus right` from
501
+ the lane would carry the focus away from it."""
502
+ if not key.startswith('\033[<'):
503
+ return True
504
+ if key[-1:] != 'M':
505
+ return False # a release (`m`), or a report cut at the end of the read
506
+ try:
507
+ button = int(key[3:-1].split(';')[0])
508
+ except ValueError:
509
+ return False
510
+ return not button & (32 | 64)
511
+
512
+
513
+ def proves_focus(data):
514
+ """Whether any key of `data` (cut as key_length() cuts it) proves this pane has the keyboard (moves_focus)."""
515
+ i = 0
516
+ while i < len(data):
517
+ n = key_length(data, i)
518
+ if moves_focus(data[i:i + n]):
519
+ return True
520
+ i += n
521
+ return False
522
+
523
+
524
+ class Worker:
525
+ """The sidebar's one action thread (cockpit v3 S9, LNS-67): the slow verbs — a close, `P`'s write, a new shell — run
526
+ here, one at a time in the order they were asked for, and the UI thread (run(): keys, ticks, frames) never waits for
527
+ them. A job's answer goes to `results`, which run()'s tick drains with get_nowait (Sidebar.finish_actions): no
528
+ timer, no second select, no lock the UI thread waits on. The thread starts with the first job and is a daemon.
529
+
530
+ The two threads share nothing but the two queues: a job gets its backend and arguments when the key is pressed
531
+ (submit, on the UI thread) and never touches the Sidebar; only the UI thread applies an answer. Serial on purpose:
532
+ two closes never contend for the registry lock inside one process. The lock waits a job meets — statusd's hot
533
+ sync, the timer's sync, another device's sidebar holding `registry.lock` — are bounded where the lock is taken
534
+ (lanes_lib.registry_lock, REGISTRY_LOCK_S) and happen here, never on the UI thread."""
535
+
536
+ def __init__(self):
537
+ self.jobs, self.results = queue.Queue(), queue.Queue()
538
+ self.thread = None
539
+
540
+ def submit(self, key, call, *args):
541
+ """Queue `call(*args)`; its answer is filed under `key`."""
542
+ self.jobs.put((key, call, args))
543
+ if self.thread is None:
544
+ self.thread = threading.Thread(target=self.serve, name='lanes-sidebar-actions', daemon=True)
545
+ self.thread.start()
546
+
547
+ def serve(self):
548
+ while True:
549
+ key, call, args = self.jobs.get()
550
+ try:
551
+ answer = (key, True, call(*args))
552
+ except Exception as error: # every failure is an answer: one that escaped would end the thread, and the
553
+ answer = (key, False, error) # row would wait for an answer for ever
554
+ self.results.put(answer)
555
+
556
+ def answers(self):
557
+ """Every answer filed since the last call, oldest first: [(key, ok, value or the exception)]. Never blocks."""
558
+ out = []
559
+ while True:
560
+ try:
561
+ out.append(self.results.get_nowait())
562
+ except queue.Empty:
563
+ return out
564
+
565
+
566
+ def failure_text(error):
567
+ """What the footer says about a job that raised: its own words, else its type (a bare exception says nothing)."""
568
+ return str(error) or type(error).__name__
569
+
570
+
571
+ def promote_job(promote, spawn, workspace, entry, registry_path, name):
572
+ """`P`'s write, on the worker: declare the session under `name` (session_actions.promote_to_registered, against the
573
+ registry as published now), then start `lanes sync` -> (the id sync is expected to give the lane, or None; the sync's
574
+ handle, or None when it could not be started — statusd's hot sync starts one on the changed terminals.json)."""
575
+ new_id = promote(workspace, entry, read_json(registry_path) or {}, name=name)
576
+ try:
577
+ sync = spawn([LANES_BIN, 'sync'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
578
+ except OSError:
579
+ sync = None
580
+ return (new_id if isinstance(new_id, str) else None), sync
581
+
582
+
311
583
  class Sidebar:
312
584
  # Class-level defaults for the registration state, so a Sidebar built without `__init__` (the
313
585
  # alignment gate constructs one with `__new__` and sets only the fields its rule needs) reads the
314
586
  # 'no registration in progress' shape rather than raising; `__init__` assigns the live values.
315
587
  registration, registration_choices, workspaces = None, (), ()
316
- renaming, rename_value, rename_entry = False, '', None
317
- promoting, promote_entry = False, None
318
- attach_only = False
319
- confirming = None # the entry `x` / `×` asked to close; `y` closes it, any other key cancels
588
+ # The one prompt the footer asks (cockpit v3 S6): None, or {'verb': 'close'|'rename'|'promote', 'entry': the row it
589
+ # acts on, 'stage': 'name'|'confirm', 'value': the name typed / confirmed, 'armed': `y` pressed at a confirmation}.
590
+ # It replaced S3's confirming / renaming / promoting fields; those names remain as read-only views (compat block).
591
+ prompt = None
592
+ _message, message_at = '', 0.0 # the `message` property's text and its time.monotonic() stamp (cockpit v3 S6)
593
+ undo = None # {'record': what registrations.unregister returned, 'at': monotonic} for UNDO_S (S7)
594
+ # `P` on its way (cockpit v3 S8; read-only defaults, every write assigns fresh dicts): `pending` maps the session's
595
+ # old id to the id lanes sync is expected to give its lane (session_actions.promoted_id); `pending_rows` holds, by
596
+ # the same old id, {'row': the row as shown — its new name, `pending: 'promote'` — 'at': monotonic}.
597
+ pending, pending_rows = {}, {}
598
+ # The slow verbs on their way (cockpit v3 S9; read-only defaults, every write assigns a fresh dict): `worker` is the
599
+ # Worker (started by the first one), `actions` maps a job's key — the row's id, or `<ws>--new-shell` — to
600
+ # {'verb': 'close'|'promote'|'new-shell', 'entry': the row asked about, …} until run()'s tick takes its answer.
601
+ worker, actions = None, {}
602
+ registration_return = (0, 0) # (cursor, top) when the Add to workspace picker opened: cancel_registration (S8)
603
+ attach_only = False # handle(): an action gave the keyboard back to the lane during this read (S3)
604
+ mobile, filtering, armed = False, False, False
320
605
  seen = {} # read-only default; mark_seen() always assigns a fresh dict
321
606
  tone = TONE16
322
607
  glyphs = tokens.glyph_set('unicode')
323
608
  color, glyph_mode, variant = '16', 'unicode', 'dark'
609
+ vivid = False # color in VIVID (apply_look): the modes whose colours Lanes defines (cockpit v3 P2)
610
+ dim, notify = '90', True # the 16-colour dim's SGR and OSC 9 on/off (apply_look, resolve_dim_notify; P4)
324
611
  theme_pending, theme_deadline, osc_buffer = False, 0.0, ''
325
612
  last_visible = () # the list visible() last returned: the one `self.cursor` indexes
326
613
  economy = {} # read-only default; reload() assigns the loaded economy.json (load_economy)
@@ -331,7 +618,37 @@ class Sidebar:
331
618
  board_titles = {} # workspace id -> title, from the registry (reload)
332
619
  strip_row = False # render(): the strip row (economy or fleet attention) is drawn this frame
333
620
  last_counts = '' # the header counts the last OSC 2 title carried ('' = none sent; spec §4.9)
334
- notified = {} # read-only default; __init__ assigns: lane id -> the needs-you status already notified
621
+ last_asking = None # the prompt verb the last OSC 2 title carried (a phone only; cockpit v3 S5)
622
+ notified = {} # read-only default; __init__ assigns: lane id -> the needs-you entry already notified
623
+ focused = False # this pane has the keyboard: a focus report or a key said so (cockpit v3 S4)
624
+ focus_reported = False # Zellij has sent a focus report: from then on `focused` decides, not the 3 s timer
625
+ legend_until = 0.0 # time.monotonic() at which the no-report focus legend ends (handle())
626
+ legend_shown = False # render(): the frame on screen carries the focus legend
627
+ command_path = '' # views/<device>/<ws>.sidebar.cmd (__init__; DM-LANES-SIDEBAR-CMD)
628
+ # The owner's profile (cockpit v3 R3, LNS-76; read-only defaults, every write assigns fresh values): `login` and
629
+ # `profile_path` (users/<login>.json) of the person this device belongs to, None for a device nobody owns — which
630
+ # never stats, reads or writes a profile. `profile_look` is the profile's look (layered_look), `device_meta` the
631
+ # device file as last read, `profile_marks` {lane: when} and `last_open` the opened-lane marks the profile has not
632
+ # taken yet (its lock was busy), written again by poll_profile at `profile_retry_at` (time.monotonic()).
633
+ login, profile_path = None, None
634
+ profile_look, device_meta = {}, {}
635
+ profile_workspaces = {} # the profile's `workspaces{order, hidden}` (R4, LNS-77): the phone's owner board
636
+ # The profile's `lanes{pins, hidden}` (cockpit v3 R5, LNS-78; read-only defaults, take_arrangement assigns fresh
637
+ # sets) and `o`'s toggle: hidden lanes shown again, faint.
638
+ lane_pins, lane_hidden, show_hidden = frozenset(), frozenset(), False
639
+ body_map = None # render(): the entry index each desktop body row shows (a click's row), None = top + row
640
+ # Which lanes (cockpit v3 V2, LNS-84): the version of the tree this process runs (lanes_lib.REPO, read once) and of
641
+ # the installed one (lanes_lib.installed_tree()/VERSION, re-read by reload() every VERSION_RECHECK_S at `version_at`).
642
+ tree_version = current_version = ''
643
+ current_path, version_at = '', 0.0
644
+ # The self-swap (cockpit v3 U5): `swappable` is set by recheck_version (a re-exec would run another tree; never in
645
+ # the first VERSION_RECHECK_S of a process), `restored` when __init__ took a predecessor's .sidebar.state, whose
646
+ # cursor lane `restore_id` the first reload puts the cursor on.
647
+ swappable, restored, restore_id = False, False, None
648
+ profile_marks, last_open, profile_retry_at = {}, None, 0.0
649
+ # W9 U6: whether clients/<device>.update-ready existed at the last look (poll_update), when to look next, and until
650
+ # when a rebuild `r` started counts as on its way (monotonic seconds)
651
+ update_ready, update_poll_at, rebuild_until = False, 0.0, 0.0
335
652
 
336
653
  def __init__(self, device, workspace, slot='main', viewports='1'):
337
654
  self.device, self.workspace, self.slot = device, workspace, slot
@@ -349,6 +666,10 @@ class Sidebar:
349
666
  self.view_dir = os.path.join(STATE, 'views', device)
350
667
  self.target_file = self.slot_file(slot, 'target')
351
668
  self.pid_file = self.slot_file(slot, 'pid')
669
+ # Ctrl-Alt-/ and Ctrl-Alt-u hand their key over as this file (`lanes sidebar-key`, cockpit v3 S4): reload()'s
670
+ # sixth stamp field, and poll_command() before every read of keys.
671
+ self.command_path = L.sidebar_key_path(self.view_dir, workspace)
672
+ self.focused, self.focus_reported, self.legend_until, self.legend_shown = False, False, 0.0, False
352
673
  self.registry_path = os.path.join(STATE, 'registry.json')
353
674
  self.status_path = os.path.join(STATE, 'status.json')
354
675
  self.economy_path = os.path.join(STATE, 'economy.json') # the opt-in poller's projection (Task 11)
@@ -360,9 +681,11 @@ class Sidebar:
360
681
  self.status, self.unregistered, self.zmx = {}, [], []
361
682
  self.cursor, self.top = 0, 0
362
683
  self.filter, self.filtering = '', False
363
- self.renaming, self.rename_value, self.rename_entry = False, '', None
364
- self.promoting, self.promote_entry = False, None
365
- self.confirming = None
684
+ self.prompt = None
685
+ self.undo = None
686
+ self.pending, self.pending_rows, self.registration_return = {}, {}, (0, 0)
687
+ self.worker, self.actions = None, {}
688
+ self.handed_off = False
366
689
  self.dirty = True
367
690
  self.color, self.glyph_mode, self.variant = None, None, None
368
691
  self.theme_pending, self.theme_deadline, self.osc_buffer = False, 0.0, ''
@@ -372,14 +695,24 @@ class Sidebar:
372
695
  self.economy_frozen = False # economy_aged() at the last poll_economy(): a flip repaints
373
696
  self.showing_keys, self.strip_row = False, False
374
697
  self.fleet, self.board_titles = {}, {}
375
- self.last_counts, self.notified = '', {} # '' not None: an agent-less workspace never emits a title
376
- self.apply_look(meta)
377
- # `done` is sticky until the lane is opened from this device: lane id -> the time it was last opened. Read
378
- # once here and again only when a target file changes (never per tick: spec §8's stat budget), written by
698
+ self.last_counts, self.last_asking, self.notified = '', None, {} # '' not None: no agents, no title
699
+ # The person this device belongs to (cockpit v3 R3, LNS-76): their profile's look is painted beneath the device
700
+ # file's --device-only keys (layered_look) and their opened-lane marks join this device's. None for a device
701
+ # nobody owns: today's sidebar.
702
+ self.device_meta = meta
703
+ self.login, self.profile_path = self.profile_of(meta)
704
+ self.profile_marks, self.last_open, self.profile_retry_at = {}, None, 0.0
705
+ profile = self.read_profile()
706
+ self.apply_look(self.look_of(meta))
707
+ # `done` is sticky until the lane is opened: lane id -> the time it was last opened, from this device's file
708
+ # and — on a device with an owner — from the person's profile, the later mark per lane. Read once here and
709
+ # again only when a target file or the profile changes (never per tick: spec §8's stat budget), written by
379
710
  # mark_seen() and by `lanes next|prev`.
380
711
  self.seen_path = os.path.join(STATE, 'clients', device + '.seen.json')
381
712
  self.seen = L.clean_seen(read_json(self.seen_path))
382
- self.stamp = (0, 0, 0, 0, 0) # registry, status, targets, device look, economy
713
+ self.merge_seen(profile)
714
+ # registry, status, targets, device look, economy, sidebar.cmd, profile (0 and never stat()ed when ownerless)
715
+ self.stamp = (0, 0, 0, 0, 0, 0, 0)
383
716
  self.size = (0, 0)
384
717
  self.dirty = True
385
718
  self.message = ''
@@ -388,6 +721,14 @@ class Sidebar:
388
721
  self.armed, self.paste_buffer = False, None
389
722
  self.upload = None # {'id', 'spool', 'names', 'at'} while a request is in flight
390
723
  self.upload_note, self.upload_note_at = '', 0
724
+ # Which lanes (cockpit v3 V2): this tree's version once; the installed one now and every 30 s (recheck_version).
725
+ self.tree_version, self.current_path = shown_version(L.REPO), str(L.installed_tree(L.REPO))
726
+ self.current_version = (self.tree_version if self.current_path == str(L.REPO)
727
+ else shown_version(self.current_path))
728
+ self.version_at = time.monotonic()
729
+ # A predecessor that swapped itself out left its cursor, scroll, seen marks and filter (cockpit v3 U5).
730
+ self.swappable, self.restore_id = False, None
731
+ self.restored = self.take_state()
391
732
 
392
733
  def slot_file(self, slot, kind):
393
734
  return os.path.join(self.view_dir, f'{self.workspace}.{slot}.{kind}')
@@ -401,21 +742,24 @@ class Sidebar:
401
742
 
402
743
  # ---------------------------------------------------------------- data
403
744
  def reload(self, force=False):
745
+ self.recheck_version()
746
+ if not force and self.swap_due(): # run()'s tick only: a forced reload is an action's, never cut short
747
+ self.self_swap()
404
748
  stamp = (mtime(self.registry_path), mtime(self.status_path),
405
749
  sum(mtime(self.slot_file(slot, 'target')) for slot in SLOTS[:self.viewports]),
406
- mtime(self.look_path), mtime(self.economy_path))
750
+ mtime(self.look_path), mtime(self.economy_path), mtime(self.command_path), self.profile_stamp())
407
751
  if stamp == self.stamp and not force:
408
752
  return
409
753
  if force:
410
754
  self.row_cache.clear() # `r` and every forced reload repaint every row from scratch (spec §4.11)
411
755
  # The cursor follows the LANE, not the index (spec §4.5, §10): a lane that changes section moves rows above
412
- # the cursor, so remember which lane it is on and re-find it after the reload.
413
- cursor_id = self.cursor_id
756
+ # the cursor, so remember which lane it is on and re-find it after the reload — or, on the first reload after
757
+ # a self-swap (U5), the lane the predecessor's cursor was on.
758
+ cursor_id, self.restore_id = self.restore_id or self.cursor_id, None
414
759
  if stamp[0] != self.stamp[0] or force:
415
760
  registry = read_json(self.registry_path) or {}
416
761
  boards = registry.get('workspaces', [])
417
762
  self.workspaces = boards
418
- self.mobile_owner = self.mobile and bool(boards) and boards[0]['id'] == self.workspace
419
763
  self.mobile_titles = {w.get('title') or w['id']: w['id'] for w in boards}
420
764
  self.board_titles = {w['id']: w.get('title') or w['id'] for w in boards}
421
765
  self.mobile_titles.update({tool['tab']: tool['view'] for tool in registry.get('tools', [])})
@@ -431,16 +775,44 @@ class Sidebar:
431
775
  self.status = status.get('lanes', {})
432
776
  self.unregistered = (status.get('unregistered') or {}).get(self.workspace, [])
433
777
  self.zmx = (status.get('zmx') or {}).get(self.workspace, [])
778
+ self.keep_moved_row()
434
779
  if stamp[0] != self.stamp[0] or stamp[1] != self.stamp[1] or force:
435
780
  self.fleet = self.fleet_counts()
436
- if stamp[3] != self.stamp[3] or force:
437
- self.apply_look(read_json(self.look_path) or {})
781
+ look = stamp[3] != self.stamp[3] or force
782
+ profile = stamp[6] != self.stamp[6] or force
783
+ if look:
784
+ meta = read_json(self.look_path)
785
+ self.device_meta = meta if isinstance(meta, dict) else {}
786
+ # The device file is where its owner is recorded (`lanes device claim`, create_session, record_base): a
787
+ # change may hand the device to someone, or to no one. Then the marks and the look are the new owner's.
788
+ owner = self.profile_of(self.device_meta)
789
+ if owner != (self.login, self.profile_path):
790
+ self.login, self.profile_path = owner
791
+ self.profile_marks, self.last_open = {}, None # unwritten marks were for the previous profile
792
+ self.seen = L.clean_seen(read_json(self.seen_path)) # this device's own; the new profile's below
793
+ stamp = stamp[:6] + (self.profile_stamp(),)
794
+ profile = True
795
+ before = self.profile_look
796
+ if profile:
797
+ # The person's profile changed — another device opened a lane, `lanes attach` recorded a look there: its
798
+ # marks join this device's (the later one per lane) and its look is painted beneath the device file's
799
+ # --device-only keys.
800
+ self.merge_seen(self.read_profile())
801
+ if look or self.profile_look != before: # a profile change that moved only `seen` re-resolves no look
802
+ self.apply_look(self.look_of(self.device_meta))
803
+ if profile:
804
+ self.fleet = self.fleet_counts() # hidden lanes and boards leave the fleet strip too (R5)
805
+ if stamp[0] != self.stamp[0] or profile:
806
+ # The phone's owner board (poll_mobile_view's one watcher): the first board the person sees (R4) — a
807
+ # hidden board has no tab to watch from.
808
+ first = users.arrange(self.workspaces, {'workspaces': self.profile_workspaces})
809
+ self.mobile_owner = self.mobile and bool(first) and first[0].get('id') == self.workspace
438
810
  if stamp[4] != self.stamp[4] or force:
439
811
  # The seventh stat() of a tick at one viewport (spec §8): re-read only when the poller replaced the file.
440
812
  # A missing file (no poller: the default) is {} — no economy tail on any row, the strip row yields.
441
813
  self.economy = load_economy(read_json(self.economy_path)) if stamp[4] else {}
442
814
  self.economy_tiers = self.tier_mix()
443
- if stamp[2] != self.stamp[2] and self.stamp != (0, 0, 0, 0, 0): # not the first reload: __init__ read it
815
+ if stamp[2] != self.stamp[2] and any(self.stamp[:5]): # not the first reload: __init__ read it
444
816
  # A viewport was pointed at a lane — by this sidebar, or by `lanes next|prev` (Ctrl-Alt-Down/Up, plan
445
817
  # Task 19), which marks the lane seen in the device's file as open_lane does. Merge the file once per
446
818
  # such change (never per tick: spec §8), so a `done` lane opened that way leaves Done here too.
@@ -448,62 +820,241 @@ class Sidebar:
448
820
  for key, value in self.seen.items():
449
821
  merged[key] = max(value, merged.get(key, value))
450
822
  self.seen = merged # a fresh dict, never the class default mutated in place
823
+ command = stamp[5] != self.stamp[5] # never `or force`: `r` must not replay the last chord
451
824
  self.stamp = stamp
452
825
  self.dirty = True
453
- # `top` is kept (no reset when the list length changes) and shifted by the rows inserted above the cursor, so
454
- # the cursor's lane stays on its screen row; render() still clamps both on every draw.
826
+ # A promoted session whose lane the registry now lists: the cursor on its row goes to the lane (cockpit v3 S8).
827
+ cursor_id = self.settle_pending(cursor_id)
828
+ # `top` is pinned (cockpit v3 S2): the cursor goes with its lane, the list does not move under it; render()
829
+ # scrolls only when the cursor's lane would leave the body, and bounds `top` by the list.
455
830
  self.resolve_cursor(self.visible(), cursor_id)
831
+ if command:
832
+ self.take_command(stamp[5]) # after the cursor is placed: a filter puts it on the top match
833
+
834
+ def recheck_version(self, now=None):
835
+ """reload()'s version check (cockpit v3 V2): once VERSION_RECHECK_S has passed since the last one, read the
836
+ installed tree's VERSION (one open, no stat; a checkout, whose current tree is its own, reads nothing) -> True
837
+ when it changed (one repaint: the footer's version note and the `?` row). Every other tick costs a clock read."""
838
+ now = time.monotonic() if now is None else now
839
+ if now - self.version_at < VERSION_RECHECK_S:
840
+ return False
841
+ self.version_at = now
842
+ if self.current_path == str(L.REPO):
843
+ return False
844
+ version = shown_version(self.current_path)
845
+ # U5: a swap is possible only when the path this process started through now resolves to another tree — a
846
+ # realpath (a few lstat, never a stat), only on this 30 s tick and only while the versions differ.
847
+ self.swappable = (bool(version) and version != self.tree_version
848
+ and os.path.realpath(LAUNCH_TREE) != os.path.realpath(L.REPO))
849
+ if version == self.current_version:
850
+ return False
851
+ self.current_version, self.dirty = version, True
852
+ return True
853
+
854
+ # ------------------------------------------------------------- the self-swap (cockpit v3 U5)
855
+ def idle(self):
856
+ """Nothing would be lost by replacing this process now: no prompt, no filter being typed, no armed strip, no
857
+ Add to workspace picker or key sheet, no paste half read, no action on the worker (a close mid-kill), no
858
+ promotion held (S8), no undo offer (S7), no upload in flight, no profile mark waiting for its retry (R3), and no
859
+ rebuild `r` started (W9 U6, rebuild_view): that rebuild ends this process with its view, so swapping first
860
+ would only repaint once for nothing."""
861
+ return not (self.prompt or self.filtering or self.armed or self.registration or self.showing_keys
862
+ or self.paste_buffer is not None or self.actions or self.pending_rows or self.undo or self.upload
863
+ or self.profile_marks or self.last_open or time.monotonic() < self.rebuild_until)
864
+
865
+ def swap_due(self):
866
+ """reload()'s tick: the installed version differs from this tree's, a re-exec would run it (`swappable`), and
867
+ the sidebar is idle. In-memory flags only: no I/O."""
868
+ return (self.swappable and bool(self.current_version) and self.current_version != self.tree_version
869
+ and self.idle())
870
+
871
+ def state_path(self):
872
+ return self.slot_file(self.slot, 'sidebar.state')
873
+
874
+ def self_swap(self):
875
+ """Hand this view to the installed tree (DM-LANES-SIDEBAR-STATE): write {version 1, cursor_id, top, seen, filter,
876
+ at, from, to} atomically (0600) and exit SWAP_EXIT — run()'s `finally` writes stop_bytes and restores the
877
+ terminal's termios, and the wrapper re-executes itself from `current` at once. A state that cannot be written
878
+ (a full disk) exits nothing: the swap waits for the next recheck, as `swappable` is cleared."""
879
+ state = {'version': 1, 'cursor_id': self.cursor_id, 'top': self.top, 'seen': self.seen, 'filter': self.filter,
880
+ 'at': time.time(), 'from': self.tree_version, 'to': self.current_version}
881
+ try:
882
+ L.atomic_write(Path(self.state_path()), json.dumps(state, sort_keys=True) + '\n')
883
+ except OSError:
884
+ self.swappable = False
885
+ return
886
+ sys.exit(SWAP_EXIT)
887
+
888
+ def take_state(self):
889
+ """__init__: take a predecessor's .sidebar.state and delete it -> True when one was taken. Read leniently, each
890
+ field on its own: a lane id (at most 256 characters) the first reload puts the cursor on, a non-negative `top`, the
891
+ seen marks (merged, the later per lane), a printable filter of at most 200 characters. A file that is not a v1
892
+ object, or whose `at` is more than STATE_FRESH_S from now, is no swap's: deleted unread."""
893
+ path = self.state_path()
894
+ document = read_json(path)
895
+ try:
896
+ os.unlink(path)
897
+ except OSError:
898
+ pass
899
+ if not isinstance(document, dict) or document.get('version') != 1:
900
+ return False
901
+ at = number(document.get('at'))
902
+ if at is None or abs(time.time() - at) > STATE_FRESH_S:
903
+ return False
904
+ cursor_id, top, text = document.get('cursor_id'), document.get('top'), document.get('filter')
905
+ if isinstance(cursor_id, str) and 0 < len(cursor_id) <= 256:
906
+ self.restore_id = cursor_id
907
+ if isinstance(top, int) and not isinstance(top, bool) and top >= 0:
908
+ self.top = top
909
+ if isinstance(text, str) and len(text) <= OSC_TEXT_MAX and text.isprintable():
910
+ self.filter = text
911
+ seen = dict(self.seen)
912
+ for lane, mark in L.clean_seen(document.get('seen')).items():
913
+ seen[lane] = max(mark, seen.get(lane, mark))
914
+ self.seen = seen
915
+ return True
916
+
917
+ def start_cursor(self):
918
+ """run()'s start, after the first reload: the cursor on the lane this pane's viewport shows — unless a
919
+ predecessor's state put it where it was before the swap."""
920
+ if self.restored:
921
+ return
922
+ target = self.current_target()
923
+ for index, entry in enumerate(self.visible()):
924
+ if entry['id'] == target:
925
+ self.cursor = index
926
+
927
+ def version_note(self, cols):
928
+ """The footer's version rung (cockpit v3 V2), '' while this sidebar runs the installed tree (or either version
929
+ is unknown, or an update is ready — footer_text's `update ready · reload (r)` says it then): ` server 1.2.0 · view 1.1.0 — press ?`; at the sidebar's 36 columns `press` gives way (` — ?`),
930
+ then the hint; a longer pair is clipped by footer_text. Every separator is a mark: seven-bit in ASCII."""
931
+ current, tree = self.current_version, self.tree_version
932
+ # A pending rebuild (W9 U6's update ready, poll_update) is the rung that says what to do: `r` brings this view
933
+ # onto the installed release, so the note gives way to it rather than hiding it above the message.
934
+ if not current or not tree or current == tree or self.update_ready:
935
+ return ''
936
+ g = self.glyphs['marks']
937
+ head = f' server {current} {g["sep"]} view {tree}'
938
+ for tail in (f' {g["dash"]} press ?', f' {g["dash"]} ?', ''):
939
+ if cell_width(head + tail) <= cols - 1:
940
+ return head + tail
941
+ return head
456
942
 
457
943
  # ------------------------------------------------------------------ look
944
+ def profile_of(self, meta):
945
+ """(login, users/<login>.json) of the person this device belongs to (cockpit v3 R3, LNS-76), or (None, None).
946
+ The owner is users.login_for over this device and the base its file records (`meta`) — exactly what `lanes
947
+ attach` writes a look for (bin/lanes profile_for, R2), so the profile painted is the one written: a member
948
+ record of the device or of the machine it is a window of whenever one exists, else the owner the file records.
949
+ members/ and users/ are the ones beside this sidebar's STATE. A login too long for a profile file name has no
950
+ profile (users.path refuses it): today's sidebar. Reads at most two small members/ files, only at start and
951
+ when the device file changes — never per tick."""
952
+ login = users.login_for(self.device, meta, members_dir=os.path.join(STATE, 'members'))
953
+ if not login:
954
+ return None, None
955
+ try:
956
+ return login, str(users.path(login, os.path.join(STATE, 'users')))
957
+ except L.LanesError:
958
+ return None, None
959
+
960
+ def profile_stamp(self):
961
+ """reload()'s seventh stamp field: the profile's mtime — 0, with no stat at all, on a device nobody owns."""
962
+ return mtime(self.profile_path) if self.profile_path else 0
963
+
964
+ def read_profile(self):
965
+ """The person's profile as users.read normalizes it (None: no owner, no file yet, or a file this release does
966
+ not read), keeping its look for layered_look."""
967
+ profile = users.read(self.login, os.path.dirname(self.profile_path)) if self.profile_path else None
968
+ self.profile_look = dict(profile['look']) if profile else {}
969
+ self.take_arrangement(profile)
970
+ return profile
971
+
972
+ def take_arrangement(self, profile):
973
+ """The profile's arrangement into this sidebar: `workspaces{order, hidden}` (R4) and `lanes{pins, hidden}` (R5);
974
+ none without a profile. Fresh values, never mutated in place."""
975
+ self.profile_workspaces = dict(profile['workspaces']) if profile else {}
976
+ self.lane_pins = frozenset(profile['lanes']['pins']) if profile else frozenset()
977
+ self.lane_hidden = frozenset(profile['lanes']['hidden']) if profile else frozenset()
978
+
979
+ def merge_seen(self, profile):
980
+ """The profile's opened-lane marks into this sidebar's: the later mark per lane wins (users.merge's rule), so a
981
+ reload never re-shows a lane this device opened after the profile's mark. A fresh dict, never mutated in place."""
982
+ if not profile or not profile['seen']:
983
+ return
984
+ seen = dict(self.seen)
985
+ for lane, at in profile['seen'].items():
986
+ seen[lane] = max(at, seen.get(lane, at))
987
+ self.seen = seen
988
+
989
+ def look_of(self, meta):
990
+ """What apply_look resolves: the device file alone on a device nobody owns (byte for byte today's input), the
991
+ profile's look layered over it on one with an owner (layered_look: only --device-only keys win over it)."""
992
+ return layered_look(self.profile_look, meta) if self.profile_path else meta
993
+
458
994
  def apply_look(self, meta):
459
995
  """Resolve colour / theme / glyph modes from the device file and the environment (spec §2.4). Runs at start and
460
996
  whenever devices/<device>.json changes, so `lanes attach DEVICE --ascii|--theme|--color` against a LIVE session
461
997
  reaches every sidebar of that device within one tick (Zellij panes keep the server's environment from session
462
- creation: an environment variable can never reach a running sidebar). Publishes the glyph mode for view.sh."""
998
+ creation: an environment variable can never reach a running sidebar). Publishes the glyph mode and the colour
999
+ mode for view.sh (clients/<device>.glyphs and .color, beside .theme). The 16-colour dim and OSC 9 resolve here
1000
+ too, from the same file's `dim`/`notify` (`--dim`, `--notify`; cockpit v3 P4, DEF-1577), never per frame.
1001
+ On a device with an owner `meta` is layered_look's (cockpit v3 R3): the owner's profile beneath the device
1002
+ file's --device-only keys, and this runs when either changes — a look recorded on another of the person's
1003
+ devices included."""
463
1004
  color, theme, glyphs = resolve_modes(os.environ, meta)
464
- changed = (color, glyphs) != (self.color, self.glyph_mode)
465
- self.color, self.glyph_mode = color, glyphs
466
- variant_changed = False
1005
+ dim, notify = resolve_dim_notify(os.environ, meta)
1006
+ changed = (color, glyphs, dim) != (self.color, self.glyph_mode, self.dim)
1007
+ self.color, self.glyph_mode, self.vivid = color, glyphs, color in VIVID
1008
+ self.dim, self.notify = dim, notify == '1'
467
1009
  if theme != 'auto':
468
- variant_changed = theme != self.variant
1010
+ changed = changed or theme != self.variant
469
1011
  self.variant, self.theme_pending = theme, False
470
1012
  elif self.variant is None: # first resolution under `auto`: paint dark, ask the terminal once
471
1013
  self.variant, self.theme_pending, self.theme_deadline = 'dark', True, time.monotonic() + THEME_GRACE
472
- changed = changed or variant_changed
473
- self.tone = tokens.sgr_table(self.color, self.variant)
1014
+ self.tone = self.tone_table(self.variant)
474
1015
  self.glyphs = tokens.glyph_set(self.glyph_mode)
475
- glyphs_file = os.path.join(STATE, 'clients', self.device + '.glyphs')
476
- if changed or not os.path.exists(glyphs_file):
477
- try:
478
- L.atomic_write(Path(glyphs_file), self.glyph_mode + '\n')
479
- except OSError:
480
- pass # view.sh then keeps its default; the sidebar itself is unaffected
1016
+ self.publish_stamp('glyphs', self.glyph_mode)
1017
+ self.publish_stamp('color', self.color)
481
1018
  if theme != 'auto':
482
- self.publish_theme(variant_changed)
1019
+ self.publish_stamp('theme', self.variant)
483
1020
  if changed:
484
1021
  self.row_cache.clear()
485
1022
  self.dirty = True
486
1023
 
487
1024
  def apply_theme(self, variant):
488
- """The OSC 11 answer (or the grace expiry, with the current variant): repaint on a change and publish it."""
1025
+ """The OSC 11 answer (or the grace expiry, with the current variant): repaint on a change, and publish what is
1026
+ painted whether or not it changed — the stamp may be an earlier attach's (DEF-1775)."""
489
1027
  self.theme_pending = False
490
1028
  if not variant:
491
1029
  return
492
- changed = variant != self.variant
493
- if changed:
494
- self.variant, self.tone, self.dirty = variant, tokens.sgr_table(self.color, variant), True
1030
+ if variant != self.variant:
1031
+ self.variant, self.tone, self.dirty = variant, self.tone_table(variant), True
495
1032
  self.row_cache.clear()
496
- self.publish_theme(changed)
1033
+ self.publish_stamp('theme', self.variant)
1034
+
1035
+ def tone_table(self, variant):
1036
+ """The escapes this sidebar paints with: tokens.sgr_table for its colour mode and `variant`, the 16-colour dim
1037
+ from the resolved `dim` (P4) — never sgr_table's own LANES_DIM fallback, which the device file must win over."""
1038
+ return tokens.sgr_table(self.color, variant, dim16=DIM16[self.dim])
1039
+
1040
+ def expire_theme(self, now=None):
1041
+ """No OSC 11 answer within THEME_GRACE: the pending variant (dark, spec §2.4) is the resolution, and it is
1042
+ published. run() calls this on its tick as well as render() on a frame (DEF-1775): run() paints only a dirty
1043
+ frame, and a quiet board whose start frame came before the grace ran out may paint none for minutes, while a
1044
+ status screen beside it would read an earlier attach's stamp."""
1045
+ if self.theme_pending and (time.monotonic() if now is None else now) >= self.theme_deadline:
1046
+ self.apply_theme(self.variant)
497
1047
 
498
- def publish_theme(self, changed):
499
- """clients/<device>.theme (`dark`/`light` + newline) for `glow`: written on change or when absent, never per
500
- render."""
501
- theme_file = os.path.join(STATE, 'clients', self.device + '.theme')
502
- if changed or not os.path.exists(theme_file):
503
- try:
504
- L.atomic_write(Path(theme_file), self.variant + '\n')
505
- except OSError:
506
- pass
1048
+ def publish_stamp(self, kind, value):
1049
+ """clients/<device>.<kind> (`glyphs`, `color`, `theme`: the value + newline) for view.sh and `glow`. Compared by
1050
+ CONTENT, never gated on an in-memory change flag (DEF-1775): the file outlives the sidebar that wrote it, so an
1051
+ earlier session's stamp — another terminal's theme answer, another look — is replaced the first time this
1052
+ sidebar resolves, and a stamp that already says the same is left alone. Called on a resolution (start, a device
1053
+ file change, the OSC 11 answer or its grace), never per render."""
1054
+ try:
1055
+ L.atomic_write_if_changed(Path(STATE) / 'clients' / f'{self.device}.{kind}', value + '\n')
1056
+ except OSError:
1057
+ pass # view.sh then keeps its default; the sidebar itself is unaffected
507
1058
 
508
1059
  def strip_osc(self, data):
509
1060
  """Remove complete OSC sequences from `data`, apply OSC 11 answers, hold an incomplete OSC 11 tail for the next
@@ -562,6 +1113,45 @@ class Sidebar:
562
1113
  if banner != self.banner:
563
1114
  self.banner, self.dirty = banner, True
564
1115
 
1116
+ # ---------------------------------------------------- update ready (W9 U6)
1117
+ def update_stamp(self):
1118
+ return os.path.join(STATE, 'clients', self.device + '.update-ready')
1119
+
1120
+ def poll_update(self):
1121
+ """Keep `update_ready` — whether `lanes ensure --if-layout-changed` left clients/<device>.update-ready because
1122
+ the release installed lays this view out differently and a client was attached — at most UPDATE_POLL seconds
1123
+ old: one stat per UPDATE_POLL, never one per tick. A change repaints the footer."""
1124
+ now = time.monotonic()
1125
+ if now < self.update_poll_at:
1126
+ return
1127
+ self.update_poll_at = now + UPDATE_POLL
1128
+ ready = os.path.exists(self.update_stamp())
1129
+ if ready != self.update_ready:
1130
+ self.update_ready, self.dirty = ready, True
1131
+
1132
+ def rebuild_view(self):
1133
+ """`r` while the footer offers the update: `lanes rebuild <device>` from this tree's `bin/lanes` — the installed
1134
+ release's, as the layout names the tree through `current` — in a session of its own, so it outlives this
1135
+ sidebar, which ends with the view it rebuilds (the client reconnects: `lanes rebuilt`). A stamp gone since the
1136
+ last look (a rollback made the layout current again) makes it a plain reload; a second `r` while one rebuild is
1137
+ on its way starts none."""
1138
+ self.dirty = True
1139
+ if not os.path.exists(self.update_stamp()):
1140
+ self.update_ready = False
1141
+ self.reload(force=True)
1142
+ return
1143
+ if time.monotonic() < self.rebuild_until:
1144
+ self.message = 'Reloading the view'
1145
+ return
1146
+ try:
1147
+ subprocess.Popen([LANES_BIN, 'rebuild', self.device], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
1148
+ stderr=subprocess.DEVNULL, start_new_session=True)
1149
+ except OSError as error:
1150
+ self.message = f'reload failed: {error.strerror or error}'
1151
+ return
1152
+ self.rebuild_until = time.monotonic() + UPDATE_POLL
1153
+ self.message = 'Reloading the view'
1154
+
565
1155
  def economy_aged(self, now):
566
1156
  """True when the loaded economy.json is older than ECONOMY_STALE by its own `at` against `now` (our clock):
567
1157
  the poller stopped, so every figure in it is frozen (DEF-1528). False without a document or without a numeric
@@ -576,6 +1166,45 @@ class Sidebar:
576
1166
  if frozen != self.economy_frozen:
577
1167
  self.economy_frozen, self.dirty = frozen, True
578
1168
 
1169
+ def poll_command(self):
1170
+ """One stat() of views/<device>/<ws>.sidebar.cmd before a read's keys are handled (on_read). Zellij hands the
1171
+ keyboard back to this pane the moment the chord's floating pane exits, which is after `lanes sidebar-key` wrote
1172
+ the file but possibly before the next 1 s tick: the first letter typed after Ctrl-Alt-/ must already go into
1173
+ the filter, never become a sidebar key. Shares reload()'s sixth stamp field, so a command acts once; the other
1174
+ fields (the seventh, the profile, included) are left for reload() to compare."""
1175
+ stamp = mtime(self.command_path)
1176
+ if stamp != self.stamp[5]:
1177
+ self.stamp = self.stamp[:5] + (stamp,) + self.stamp[6:]
1178
+ self.take_command(stamp)
1179
+
1180
+ def take_command(self, stamp):
1181
+ """Act on a new sidebar command (DM-LANES-SIDEBAR-CMD, cockpit v3 S4): `filter` is the `/` key, `upload` the
1182
+ `U` key, pressed on an idle sidebar — whatever is open (a prompt, the picker, the key sheet, the other mode) is
1183
+ cancelled first, as Esc cancels it, and never typed into, so a chord can neither rename nor close anything. A
1184
+ stale, malformed or foreign file is ignored (lanes_lib.read_sidebar_key). The focus is already here (the chord
1185
+ moved it): nothing is handed back, and the footer legend says where the keyboard is."""
1186
+ key = L.read_sidebar_key(self.command_path) if stamp else None
1187
+ if not key:
1188
+ return
1189
+ self.begin_action()
1190
+ if self.prompt:
1191
+ self.cancel_prompt()
1192
+ if self.registration:
1193
+ self.cancel_registration()
1194
+ self.showing_keys = False
1195
+ if key == 'filter':
1196
+ self.armed = False
1197
+ self.filtering, self.filter = True, ''
1198
+ self.set_cursor(0)
1199
+ elif self.attach_only: # a remote workspace has no upload strip (render: `self.box`)
1200
+ self.filter, self.filtering = '', False
1201
+ self.message = 'no uploads in a remote workspace'
1202
+ else:
1203
+ self.filter, self.filtering = '', False
1204
+ self.armed = True
1205
+ self.legend_until = time.monotonic() + FOCUS_LEGEND_S
1206
+ self.dirty = True
1207
+
579
1208
  def current_target(self):
580
1209
  try:
581
1210
  with open(self.target_file) as handle:
@@ -604,13 +1233,63 @@ class Sidebar:
604
1233
  the lanes status.json knows, so a restarted sidebar does not resurrect what was already looked at.
605
1234
  Every workspace tab of a device runs its own sidebar over the SAME file, so a write merges what is on disk
606
1235
  (the later mark per lane wins) instead of replacing it with this process's view — `lanes_lib.record_seen`,
607
- which `lanes next|prev` (Ctrl-Alt-Down/Up) call too."""
1236
+ which `lanes next|prev` (Ctrl-Alt-Down/Up) call too.
1237
+ On a device with an owner (cockpit v3 R3, LNS-76) the device file keeps only what THIS device opened — its own
1238
+ record, which notify's desk presence and an older release read — and the mark, with `last_opened` {lane,
1239
+ device, at}, goes to the person's profile (save_profile), from which every other device of theirs takes it on
1240
+ its next tick. A profile write that does not get the lock within PROFILE_LOCK_S is retried by run()'s tick."""
608
1241
  since = self.agent_of(lane_id).get('since')
609
- self.seen = L.record_seen(self.seen_path, lane_id, since, self.status, self.seen)
1242
+ if not self.profile_path:
1243
+ self.seen = L.record_seen(self.seen_path, lane_id, since, self.status, self.seen)
1244
+ return
1245
+ mine = L.record_seen(self.seen_path, lane_id, since, self.status)
1246
+ seen = dict(self.seen)
1247
+ for lane, at in mine.items():
1248
+ seen[lane] = max(at, seen.get(lane, at))
1249
+ self.seen = seen
1250
+ at = mine[lane_id]
1251
+ marks = dict(self.profile_marks)
1252
+ marks[lane_id] = max(at, marks.get(lane_id, at))
1253
+ self.profile_marks, self.last_open = marks, {'lane': lane_id, 'device': self.device, 'at': at}
1254
+ self.save_profile()
1255
+
1256
+ def save_profile(self):
1257
+ """Write the marks the profile has not taken (profile_marks, last_open) through users.update as this device,
1258
+ waiting at most PROFILE_LOCK_S for its lock. -> True when the profile holds them (they are forgotten here);
1259
+ False when the write was dropped — they stay, and poll_profile writes them again PROFILE_RETRY_S later. The
1260
+ merge in users.update keeps the later mark per lane and the later `last_opened`, so a retry never lowers one."""
1261
+ marks, opened = dict(self.profile_marks), self.last_open
1262
+ if not self.profile_path or not (marks or opened):
1263
+ return False
1264
+
1265
+ def mark(profile):
1266
+ for lane, at in marks.items():
1267
+ profile['seen'][lane] = max(at, profile['seen'].get(lane, at))
1268
+ if opened:
1269
+ profile['last_opened'] = dict(opened)
1270
+ if users.update(self.login, self.device, mark, directory=os.path.dirname(self.profile_path),
1271
+ timeout=PROFILE_LOCK_S):
1272
+ self.profile_marks, self.last_open = {}, None
1273
+ return True
1274
+ self.profile_retry_at = time.monotonic() + PROFILE_RETRY_S
1275
+ return False
1276
+
1277
+ def poll_profile(self, now=None):
1278
+ """run()'s tick (cockpit v3 R3): marks a busy profile dropped are written again once PROFILE_RETRY_S has passed.
1279
+ Nothing waiting — every tick but those — is no stat, no read, no write. -> True when the profile took them."""
1280
+ if not (self.profile_marks or self.last_open):
1281
+ return False
1282
+ if (time.monotonic() if now is None else now) < self.profile_retry_at:
1283
+ return False
1284
+ return self.save_profile()
610
1285
 
611
1286
  def agent_rows(self, lanes):
612
- """[(entry, agent status or None, since or None, attention key or None)] in ONE pass over status.json — the
613
- sections, the counts and the header all read this, so a render looks each lane up once (spec §4.11)."""
1287
+ """[(entry, agent status or None, section entry stamp or None, attention key or None)] in ONE pass over
1288
+ status.json — the sections, the counts and the header all read this, so a render looks each lane up once (spec
1289
+ §4.11). The stamp is statusd's `agent.stable_since` — when the lane entered the section it is published in
1290
+ (`agent_state.settle`, cockpit v3 S1) — or, from a statusd older than S1 that publishes none, `since`. It is
1291
+ what a section is ordered by. The Done seen check keeps comparing `since`: that marks the TURN (a later turn
1292
+ finishing is news again), and `stable_since` would only mark when it reached the section."""
614
1293
  if not lanes:
615
1294
  return []
616
1295
  status_of, seen, out = self.status, self.seen, []
@@ -622,21 +1301,27 @@ class Sidebar:
622
1301
  continue
623
1302
  since = agent.get('since')
624
1303
  since = since if isinstance(since, (int, float)) else None
1304
+ entered = number(agent.get('stable_since'))
625
1305
  key = SECTION_OF.get(status)
626
1306
  if key == 'done' and entry['id'] in seen and (since or 0) <= seen[entry['id']]:
627
1307
  key = None # opened since it finished: not news (seen_done)
628
- out.append((entry, status, since, key))
1308
+ out.append((entry, status, since if entered is None else entered, key))
629
1309
  return out
630
1310
 
631
1311
  def fleet_counts(self):
632
1312
  """{other workspace's title: {needs_approval: n, needs_input: n}} from the status.json already loaded — one pass
633
1313
  over its lanes, zero extra reads (spec §4.6(c)); this workspace is never in it. Ordered as the registry lists the
634
- workspaces, so the strip does not reshuffle when status.json does."""
1314
+ workspaces, so the strip does not reshuffle when status.json does. A lane the owner hid, and every lane of a
1315
+ workspace the owner hid, is not counted (cockpit v3 R5): the person chose not to see it."""
635
1316
  found = {}
1317
+ shown = ({board.get('id') for board in users.arrange(self.workspaces, {'workspaces': self.profile_workspaces})}
1318
+ if self.profile_workspaces.get('hidden') else None)
636
1319
  for lane_id, entry in self.status.items():
637
1320
  board = lane_id.split('--', 1)[0]
638
1321
  agent = entry.get('agent') if isinstance(entry, dict) else None
639
1322
  status = agent.get('status') if isinstance(agent, dict) else None
1323
+ if lane_id in self.lane_hidden or (shown is not None and board not in shown):
1324
+ continue
640
1325
  if board != self.workspace and status in ('needs_approval', 'needs_input'):
641
1326
  counts = found.setdefault(board, {})
642
1327
  counts[status] = counts.get(status, 0) + 1
@@ -741,17 +1426,32 @@ class Sidebar:
741
1426
  """{needs, working, agents, shells} over this workspace's registry lanes: `needs` and `working` are the
742
1427
  sizes of those sections, `agents` the lanes running a harness (any status but `shell`), `shells` the rest."""
743
1428
  needs = working = agents = 0
744
- for _entry, status, _since, key in self.agent_rows(self.entries):
1429
+ lanes = self.shown_lanes()
1430
+ for _entry, status, _entered, key in self.agent_rows(lanes):
745
1431
  needs += key == 'needs'
746
1432
  working += key == 'working'
747
1433
  agents += status is not None and status != 'shell'
748
- return {'needs': needs, 'working': working, 'agents': agents, 'shells': len(self.entries) - agents}
1434
+ return {'needs': needs, 'working': working, 'agents': agents, 'shells': len(lanes) - agents}
1435
+
1436
+ def shown_lanes(self):
1437
+ """This workspace's registry lanes the list shows (cockpit v3 R5): without the ones the owner hid, unless `o`
1438
+ shows them. The header counts, the notifications and the list all read this; `self.entries` itself when
1439
+ nothing is hidden (no copy)."""
1440
+ if not self.lane_hidden or self.show_hidden:
1441
+ return self.entries
1442
+ return [entry for entry in self.entries if entry.get('id') not in self.lane_hidden]
1443
+
1444
+ def registry_lane(self, entry):
1445
+ """Whether `entry` is one of this workspace's registry lanes — what `p` pins and `h` hides. Never a divider, an
1446
+ action row, a zmx lane or a session listed under Unregistered (nothing alive is ever hidden)."""
1447
+ lane_id = entry.get('id')
1448
+ return bool(lane_id) and not entry.get('divider') and any(e.get('id') == lane_id for e in self.entries)
749
1449
 
750
1450
  def header_tally(self):
751
1451
  """[(agent status, 'glyph+count')] for the header, in `HEADER_COUNTS` order, zeros omitted; a seen `done`
752
1452
  is not counted (it left the Done section)."""
753
1453
  counts = {}
754
- for _entry, status, _since, key in self.agent_rows(self.entries):
1454
+ for _entry, status, _entered, key in self.agent_rows(self.shown_lanes()):
755
1455
  if status in HEADER_COUNTS and (status != 'done' or key):
756
1456
  counts[status] = counts.get(status, 0) + 1
757
1457
  agent = self.glyphs['agent']
@@ -798,19 +1498,26 @@ class Sidebar:
798
1498
  since = agent.get('since') if status else observed.get('activity')
799
1499
  age = age3(int(now - since) if isinstance(since, (int, float)) and since > 0 else None)
800
1500
  is_cursor, is_viewing = index == self.cursor, entry['id'] == target
801
- mark = ' '
1501
+ # Column 3: a row `P` is registering says so first (cockpit v3 S8, the dim `pending` mark), then the viewports.
1502
+ pending = bool(entry.get('pending'))
1503
+ mark, mark_role = (self.glyphs['marks']['pending'], MARKS['pending'][2]) if pending else (' ', 'accent')
802
1504
  for slot, name in zip(SLOTS[1:self.viewports], ('viewport-b', 'viewport-c')):
803
- if others.get(slot) == entry['id']:
1505
+ if others.get(slot) == entry['id'] and not pending:
804
1506
  mark = self.glyphs['marks'][name]
805
1507
  if mark == ' ' and is_viewing and not is_cursor:
806
1508
  mark = self.glyphs['marks']['viewing']
1509
+ # The owner's pin (cockpit v3 R5): column 3's lowest mark — pending, the viewports and the viewing mark win.
1510
+ pinned = entry['id'] in self.lane_pins
1511
+ if mark == ' ' and pinned:
1512
+ mark, mark_role = self.glyphs['marks']['pinned'], MARKS['pinned'][2]
1513
+ hidden = entry['id'] in self.lane_hidden # drawn only while `o` shows the hidden lanes: faint
807
1514
  # A lane statusd never observed already shows `?` as its stream glyph: one `?`, not two.
808
1515
  tail, tail_role = self.tail_for(observed if key_state != 'unknown' else {}, econ, now)
809
1516
  viewers = viewer_mark(observed).replace('·', self.glyphs['marks']['viewer'])
810
- closable = bool(entry.get('closable'))
1517
+ closable = bool(entry.get('closable')) and not pending # a row being registered offers neither control
811
1518
  can_register = closable and R.eligible(entry)
812
1519
  key = (entry.get('name'), entry.get('color'), entry.get('icon'), closable, can_register, key_state, stale,
813
- viewers, status, age, tail, tail_role, is_cursor, is_viewing, mark, cols, section_tone)
1520
+ viewers, status, age, tail, tail_role, is_cursor, is_viewing, mark, mark_role, cols, section_tone, hidden)
814
1521
  cached = self.row_cache.get(entry['id'])
815
1522
  if cached and cached[0] == key:
816
1523
  self.row_hits += 1
@@ -837,10 +1544,13 @@ class Sidebar:
837
1544
  if status:
838
1545
  agent_role = 'ink-dim' if stale else tokens.tone_of(AGENT_GLYPHS, status)
839
1546
  agent_cell = f'{tone[agent_role]}{g["agent"][status]}{reset}'
840
- mark_cell = f'{tone["accent"]}{mark}{reset}' if mark != ' ' else ' '
1547
+ mark_cell = f'{tone[mark_role]}{mark}{reset}' if mark != ' ' else ' '
841
1548
  color = entry.get('color')
842
- # The lane's identity colour (38;5;N) is a colour: under NO_COLOR (`none`) the name is plain ink.
843
- fg = f'\033[38;5;{color}m' if isinstance(color, int) and self.color != 'none' else ''
1549
+ # The lane's identity colour (38;5;N) is a colour: under NO_COLOR (`none`) the name is plain ink. Without one
1550
+ # the name is painted ink in the vivid modes (cockpit v3 P2): the terminal's own foreground is not the token's.
1551
+ # In 16 colours ink IS the default foreground (`39`), so nothing is written there.
1552
+ fg = (tone['ink-faint'] if hidden else f'\033[38;5;{color}m' if isinstance(color, int) and self.color != 'none'
1553
+ else tone['ink'] if self.vivid else '')
844
1554
  lead = (tone['bold'] if is_viewing else '') + fg
845
1555
  name_cell = f'{lead}{clipped}{reset}' if lead else clipped
846
1556
  if viewers:
@@ -850,40 +1560,77 @@ class Sidebar:
850
1560
  # are POSITIONED (CHA): a name whose terminal width disagrees with `cell_width` cannot push either control
851
1561
  # off its hit box.
852
1562
  spacer = f'\033[{max(1, cols - 8)}G{tone["tone-done"]}{g["marks"]["new"]}{reset}' if can_register else ' '
853
- stream_role = 'ink-dim' if (stale or status) else tokens.tone_of(GLYPHS, key_state)
1563
+ # Beside an agent the stream glyph used to be ink-dim (spec §4.2 col 33); in the vivid modes it keeps its own
1564
+ # tone and only a stale row dims it (cockpit v3 P2) — shell `_`, idle `·`, quiet `○` are ink-dim by token.
1565
+ stream_role = 'ink-dim' if (stale or (status and not self.vivid)) else tokens.tone_of(GLYPHS, key_state)
854
1566
  stream = f'{tone[stream_role]}{g["stream"][key_state]}{reset}'
855
1567
  tail_cell = f'{tone[tail_role]}{tail}{reset}' if tail != ' ' else ' '
856
1568
  close = f'\033[{max(1, cols - 1)}G{tone["tone-urgent"]}{g["marks"]["close"]}{reset}' if closable else ' '
857
1569
  age_role = section_tone or 'ink-dim'
858
1570
  text = (f'{cursor}{agent_cell}{mark_cell}{icon}{name_cell}{pad}{spacer}{tone[age_role]}{age}{reset} '
859
1571
  f'{stream}{tail_cell}{close}')
1572
+ if is_cursor:
1573
+ text = self.on_band(text)
860
1574
  self.row_cache[entry['id']] = (key, text)
861
1575
  return text
862
1576
 
1577
+ def on_band(self, text):
1578
+ """A painted row on the `selected` background, the cursor row (cockpit v3 P2): the band first and again after
1579
+ every reset inside, and a PLAIN reset last — the caller's `\\033[K` paints the current background, so column
1580
+ 36 stays the terminal's own. 16 colours and none have no band (`selected` is ''): the row is returned as is
1581
+ and the bold `▌` in column 1 is the cursor, as it always was; in the vivid modes the `▌` stays too, so the band
1582
+ is never the only cue (the light wash is a whisper on a white terminal)."""
1583
+ band, reset = self.tone['selected'], self.tone['reset']
1584
+ if not band:
1585
+ return text
1586
+ text = text.removesuffix(reset)
1587
+ return f'{band}{text.replace(reset, reset + band)}{reset}'
1588
+
863
1589
  def sectioned(self, lanes):
864
- """Registry lanes in attention order (spec §4.4): Needs you (by urgency, oldest first), Working (oldest
865
- first), Done (newest first, unseen only), then the rest in registry order under a workspace header — drawn
866
- only when an attention section exists, so a workspace without agents looks exactly as before."""
867
- buckets, rest = {key: [] for key, *_ in ATTENTION}, []
868
- for entry, status, since, key in self.agent_rows(lanes):
1590
+ """Registry lanes in attention order (spec §4.4, cockpit v3 S2): Needs you (by urgency, then in the order the
1591
+ lanes ENTERED the section), Working (entry order), Done (newest entry first, unseen only), then the rest in
1592
+ registry order under a workspace header. Shared by the desktop and the phone (render_mobile draws this list).
1593
+
1594
+ The entry stamp is statusd's `agent.stable_since` (agent_rows; `since` from a statusd older than S1). It moves
1595
+ only when a lane changes SECTION. `since` was the key until S2 and moved the rows: it restarts on every status
1596
+ change inside one section (needs_input -> needs_approval -> needs_input put a lane that had waited longest
1597
+ below the others) and, for a lane demoted out of Needs you, is when its turn STARTED, up to 8 s before it
1598
+ entered Working — so it slid in above lanes already working (DEF-1811). Ties (one statusd cycle) go by the
1599
+ lane's position in the registry. Once the workspace has an agent (a lane whose status is not None or `shell`,
1600
+ as counts() says), the Needs you and Working headers are always drawn, `(0)` included, so a lane moving
1601
+ between them never adds or removes a header row above the others; Done only with a member. A workspace
1602
+ without agents is exactly the single list it always was.
1603
+ A lane the owner pinned (cockpit v3 R5) leads its section — the pinned ones in registry order, then the others
1604
+ in the order above — in the remainder and the single list alike."""
1605
+ buckets, rest, agents, pins = {key: [] for key, *_ in ATTENTION}, [], False, self.lane_pins
1606
+ for index, (entry, status, entered, key) in enumerate(self.agent_rows(lanes)):
1607
+ agents = agents or status not in (None, 'shell')
869
1608
  if key is None:
870
1609
  rest.append(entry)
871
- elif key == 'needs': # urgency, then the oldest `since` first; an unknown `since` sorts last
872
- buckets[key].append(((NEEDS_RANK[status], INF if since is None else since), status, entry))
1610
+ continue
1611
+ stamp = INF if entered is None else entered # an unknown stamp sorts last
1612
+ if pins and entry.get('id') in pins:
1613
+ order = (0, index)
1614
+ elif key == 'needs':
1615
+ order = (1, NEEDS_RANK[status], stamp, index)
873
1616
  elif key == 'working':
874
- buckets[key].append((INF if since is None else since, status, entry))
875
- else: # done: newest first
876
- buckets[key].append((INF if since is None else -since, status, entry))
877
- if not any(buckets.values()):
1617
+ order = (1, stamp, index)
1618
+ else: # done: the newest entry first
1619
+ order = (1, INF if entered is None else -entered, index)
1620
+ buckets[key].append((order, status, entry))
1621
+ if pins:
1622
+ rest = [e for e in rest if e.get('id') in pins] + [e for e in rest if e.get('id') not in pins]
1623
+ if not agents:
878
1624
  return rest
879
1625
  out = []
880
1626
  for key, title, _glyph, _members in ATTENTION:
881
- if buckets[key]:
882
- bucket = sorted(buckets[key], key=itemgetter(0))
883
- # `status`: the most urgent member's, whose glyph heads the section (`── ◆ Needs you (3)`, spec §4.4)
1627
+ bucket = sorted(buckets[key], key=itemgetter(0))
1628
+ if bucket or key in ALWAYS_DRAWN:
1629
+ # `status`: the most urgent member's, whose glyph heads the section (`── ◆ Needs you (3)`, spec §4.4);
1630
+ # None for an empty section (divider_line draws its glyph ink-dim: a placeholder is not an alarm)
884
1631
  out.append({'id': '', 'divider': True, 'name': f'{title} ({len(bucket)})', 'section': key,
885
- 'status': bucket[0][1]})
886
- out += [entry for _rank, _status, entry in bucket]
1632
+ 'status': bucket[0][1] if bucket else None})
1633
+ out += [entry for _order, _status, entry in bucket]
887
1634
  if rest:
888
1635
  out.append({'id': '', 'divider': True, 'name': f'{self.title} ({len(rest)})', 'section': 'rest'})
889
1636
  return out + rest
@@ -904,13 +1651,13 @@ class Sidebar:
904
1651
 
905
1652
  def resolve_cursor(self, entries, cursor_id):
906
1653
  """The cursor is a LANE (spec §4.5). `entries` is a freshly computed list, `cursor_id` the lane the cursor was
907
- on before it: the index is re-found from the id, and `top` shifts by the rows that appeared above it, so a lane
908
- that moves between sections never moves the operator's eye. Then the index is clamped. render() and
909
- render_mobile() bound the shifted `top` by the list (DEF-1514): a list that fits the body is never scrolled."""
1654
+ on before it: the index is re-found from the id, then clamped. `top` is NOT touched (cockpit v3 S2): until S2
1655
+ it shifted by the cursor lane's delta, which scrolled the whole list every time a lane changed section above
1656
+ the cursor (plan context item 4). Now the list stays put and the cursor moves with its lane; render() and
1657
+ render_mobile() scroll only when that lane would leave the body, and bound `top` by the list (DEF-1514)."""
910
1658
  if cursor_id:
911
1659
  index = next((i for i, e in enumerate(entries) if e.get('id') == cursor_id), None)
912
- if index is not None and index != self.cursor:
913
- self.top = max(0, self.top + (index - self.cursor))
1660
+ if index is not None:
914
1661
  self.cursor = index
915
1662
  self.cursor = max(0, min(self.cursor, len(entries) - 1))
916
1663
 
@@ -942,6 +1689,7 @@ class Sidebar:
942
1689
  (the target file); relative to the cursor lane when the shown one is not in the list (nothing shown yet, or it
943
1690
  does not need you); from the first / last only when neither is — so two Tabs always visit two lanes, also
944
1691
  before open_lane has moved the target (spec §4.5)."""
1692
+ self.begin_action()
945
1693
  lanes = self.needs_you()
946
1694
  if not lanes:
947
1695
  self.message = 'nothing needs you'
@@ -959,23 +1707,38 @@ class Sidebar:
959
1707
  self.open_lane(entry)
960
1708
 
961
1709
  def osc_bytes(self, target):
962
- """The pane title when the header counts changed since the last frame, and one OSC 9 per lane per transition
963
- into needs-you — never for `target` (the lane this view shows), never on a phone, never under LANES_NOTIFY=0
964
- (spec §4.9). Strings only: they ride the frame's single write, after the footer."""
1710
+ """The pane title when the header counts changed since the last frame, and one OSC 9 per lane per ENTRY into
1711
+ needs-you — never for `target` (the lane this view shows), never on a phone, never when `notify` resolved off
1712
+ (spec §4.9; the device file's `notify: "0"`, else LANES_NOTIFY=0 — cockpit v3 P4, DEF-1577). An entry is
1713
+ statusd's `agent.stable_since` (cockpit v3 S2), so a lane whose word moves inside the
1714
+ section (needs_input -> needs_approval -> needs_input, a busy blip held by the 8 s dwell) is announced once,
1715
+ however often status.json is rewritten; until S2 each status change was a new notification (DEF-1811). From a
1716
+ statusd older than S1 (no `stable_since`) the status itself is the key: one per status, as before. Strings
1717
+ only: they ride the frame's single write, after the footer.
1718
+
1719
+ On a phone the title also ends ` · › <verb>` while a prompt that KEYS answer is open (cockpit v3 S5,
1720
+ DM-LANES-SIDEBAR-TITLE): `close`, `promote` at its y/n confirmation, `register` (Add to workspace). The phone
1721
+ shell reads it to show its reduced quick row (⏎ y Esc) on the picker tab, so those prompts can be answered
1722
+ without the keyboard. Never while a name field is open (`R`, `P`'s name stage): the row's `y` would be typed
1723
+ into the name (S5 fix cycle 1, DEF-1819). A desktop title stays counts-only (nothing reads a verb there)."""
965
1724
  out = ''
966
1725
  counts = self.header_counts()
967
- if counts != self.last_counts:
968
- self.last_counts = counts
969
- out += f'\033]2;{osc_text(self.title + (" · " + counts if counts else ""))}\033\\'
970
- quiet = self.mobile or os.environ.get('LANES_NOTIFY') == '0'
1726
+ field = bool(self.prompt) and self.prompt['stage'] == 'name' # a caret: keys are text here
1727
+ asking = self.asking if self.mobile and not field else None
1728
+ if (counts, asking) != (self.last_counts, self.last_asking):
1729
+ self.last_counts, self.last_asking = counts, asking
1730
+ tail = [part for part in (counts, asking and f'{self.glyphs["marks"]["lead"]} {asking}') if part]
1731
+ out += f'\033]2;{osc_text(" · ".join([self.title, *tail]))}\033\\'
1732
+ quiet = self.mobile or not self.notify
971
1733
  notified, now = self.notified, {}
972
- for entry in self.entries:
1734
+ for entry in self.shown_lanes(): # a lane the owner hid announces nothing (cockpit v3 R5)
973
1735
  agent = (self.status.get(entry.get('id', '')) or {}).get('agent')
974
1736
  status = agent.get('status') if isinstance(agent, dict) else None
975
1737
  if status not in NEEDS_YOU:
976
1738
  continue
977
- now[entry['id']] = status
978
- if not quiet and notified.get(entry['id']) != status and entry['id'] != target:
1739
+ entered = number(agent.get('stable_since'))
1740
+ now[entry['id']] = status if entered is None else entered
1741
+ if not quiet and notified.get(entry['id']) != now[entry['id']] and entry['id'] != target:
979
1742
  text = osc_text(f'{entry.get("name", entry["id"])}: {agent.get("name") or status}')
980
1743
  out += f'\033]9;{text}\033\\'
981
1744
  self.notified = now # a lane that left needs-you is forgotten: its next entry is news again
@@ -984,8 +1747,11 @@ class Sidebar:
984
1747
  def _visible(self):
985
1748
  if self.registration:
986
1749
  return self.registration_choices + [{'id': '', 'name': 'Cancel', 'action': 'cancel-registration'}]
987
- # Filter mode flattens the sections exactly as before (dividers are dropped below); the agent glyph stays.
988
- entries = list(self.entries) if self.filter else self.sectioned(self.entries)
1750
+ # Filter mode flattens the sections exactly as before (dividers are dropped below); the agent glyph stays. A
1751
+ # lane the owner hid is in neither (cockpit v3 R5) until `o` shows it; the filter keeps its match order (pins
1752
+ # lead a section, never the matches).
1753
+ lanes = self.shown_lanes()
1754
+ entries = list(lanes) if self.filter else self.sectioned(lanes)
989
1755
  if self.zmx:
990
1756
  entries.append({'id': '', 'divider': True, 'name': f'zmx lanes ({len(self.zmx)})'})
991
1757
  entries += self.zmx
@@ -993,10 +1759,14 @@ class Sidebar:
993
1759
  # those are orphaned or operator-made sessions that are ALIVE, and nothing alive is ever hidden —
994
1760
  # except a session that IS registered (it is listed under its workspace instead, never twice).
995
1761
  registered = {R.key(lane) for w in self.workspaces for lane in w.get('lanes', []) if lane.get('holder') == 'tmux'}
996
- unregistered = [e for e in self.unregistered if not R.eligible(e) or R.key(e) not in registered]
1762
+ unregistered = [e for e in self.closing_rows(self.promoted_rows(self.unregistered))
1763
+ if not R.eligible(e) or R.key(e) not in registered]
997
1764
  if not self.attach_only:
998
1765
  entries.append({'id': '', 'divider': True, 'name': f'Unregistered ({len(unregistered)})'})
999
- entries.append({'id': self.workspace + '--new-shell', 'name': 'New +', 'action': 'new-shell', 'icon': '+'})
1766
+ new = {'id': self.workspace + '--new-shell', 'name': 'New +', 'action': 'new-shell', 'icon': '+'}
1767
+ if new['id'] in self.actions: # a shell on its way (cockpit v3 S9): the row says so and makes no second
1768
+ new['pending'] = 'new-shell'
1769
+ entries.append(new)
1000
1770
  entries += unregistered
1001
1771
  if not self.filter:
1002
1772
  return entries
@@ -1026,11 +1796,23 @@ class Sidebar:
1026
1796
  return max(1, score)
1027
1797
 
1028
1798
  # -------------------------------------------------------------- actions
1799
+ def begin_action(self):
1800
+ """An action begins (cockpit v3 S6): the last action's message goes, so the footer never shows what an EARLIER
1801
+ action said beside what this one does. Called first by every verb — open (⏎ Space → J K 2 3, Tab, a click on a
1802
+ row), `x`/`×`, `R`, `P`, `a`/`+`, `/`, `U` (and a click on the strip), `+`, `r`, `?`, `u`, `z`, a sidebar-key
1803
+ chord. A cursor move, a typed letter, Esc, the wheel, a report begin nothing: a message outlives them for
1804
+ MESSAGE_S. An undo offer (`u`, cockpit v3 S7) is not a message: only `z`, the next `u` or UNDO_S end it."""
1805
+ self.message = ''
1806
+
1029
1807
  def open_lane(self, entry, slot=None):
1030
1808
  if entry.get('divider'):
1031
1809
  return
1810
+ self.begin_action()
1811
+ if entry.get('pending') == 'close' and self.still_pending(entry):
1812
+ return # a session being ended is no lane to look at: a view of it would be cleared under the person
1032
1813
  if entry.get('action') == 'cancel-registration':
1033
1814
  self.cancel_registration()
1815
+ self.release_focus()
1034
1816
  return
1035
1817
  if entry.get('action') == 'register-workspace':
1036
1818
  self.register_lane(entry['workspace'])
@@ -1046,71 +1828,268 @@ class Sidebar:
1046
1828
  if self.agent_of(entry['id']):
1047
1829
  self.mark_seen(entry['id']) # the one caller (spec §10): opening a lane is looking at it
1048
1830
  if self.slot_target(slot) == entry['id']:
1049
- self.focus_lane(slot)
1831
+ self.hand_off(slot)
1050
1832
  self.filter, self.filtering, self.dirty = '', False, True
1051
1833
  return
1052
1834
  # The one "write the target, wake that view.sh" (lanes_lib.show_lane), shared with `lanes next|prev`.
1053
1835
  if not L.show_lane(self.view_dir, self.workspace, slot, entry['id']):
1054
1836
  self.message = 'view not running yet'
1055
- self.focus_lane(slot)
1837
+ self.hand_off(slot)
1056
1838
  self.filter, self.filtering = '', False
1057
1839
  self.dirty = True
1058
1840
 
1059
1841
  def new_shell(self, slot=None):
1060
- self.message = 'Creating shell…'
1061
- self.render()
1062
- try:
1063
- entry = A.new_shell(self.workspace)
1064
- self.unregistered.append(entry)
1065
- self.status[entry['id']] = {'state': 'quiet'}
1066
- self.open_lane(entry, slot)
1067
- self.message = 'Created ' + entry['name']
1068
- except (L.LanesError, OSError, ValueError) as error:
1069
- self.message = str(error)
1842
+ """`+` or the New shell row: start a persistent shell on the worker (cockpit v3 S9; until S9 `docker exec` held
1843
+ the frame for up to 15 s). The New shell row carries the `pending` mark meanwhile and a second `+` makes no second
1844
+ shell; the keyboard stays here — the action has not ended — and shell_created() opens the shell where it was
1845
+ asked for, as before S9, unless the person has moved on."""
1846
+ self.begin_action()
1847
+ key = self.workspace + '--new-shell'
1848
+ if key in self.actions:
1849
+ self.still_pending({'pending': 'new-shell'})
1850
+ return
1851
+ slot = slot or self.slot
1852
+ self.message = 'Creating a shell'
1853
+ self.start_action(key, {'verb': 'new-shell', 'slot': slot, 'shown': self.slot_target(slot)},
1854
+ A.new_shell, self.workspace)
1855
+
1856
+ # ------------------------------------------------------ actions on the worker (cockpit v3 S9)
1857
+ def start_action(self, key, action, call, *args):
1858
+ """Hand `call(*args)` to the worker under `key` (Worker.submit) and remember `action` until its answer. The
1859
+ backend is the one bound now, on the UI thread, when the key is pressed. The row the job is on shows the
1860
+ `pending` mark from this frame on (closing_rows, promoted_rows, the New shell row)."""
1861
+ self.actions = {**self.actions, key: action}
1862
+ if self.worker is None:
1863
+ self.worker = Worker()
1864
+ self.worker.submit(key, call, *args)
1865
+ self.dirty = True
1866
+
1867
+ def finish_actions(self):
1868
+ """run()'s tick: apply every answer the worker has filed since the last tick, in order -> True when one came (one
1869
+ repaint). A drain, never a wait: an action still running stays `pending` until a later tick."""
1870
+ answers = self.worker.answers() if self.worker else []
1871
+ for key, ok, value in answers:
1872
+ action = self.actions.get(key)
1873
+ self.actions = {k: v for k, v in self.actions.items() if k != key}
1874
+ if action is None:
1875
+ continue
1876
+ if action['verb'] == 'close':
1877
+ self.closed(action, ok, value)
1878
+ elif action['verb'] == 'promote':
1879
+ self.promoted(action, ok, value)
1880
+ else:
1881
+ self.shell_created(action, ok, value)
1882
+ if answers:
1883
+ self.dirty = True
1884
+ return bool(answers)
1885
+
1886
+ def closing_rows(self, rows):
1887
+ """statusd's unregistered `rows` while closes are on their way: the row being closed carries `pending: 'close'`
1888
+ (column 3's mark, neither control, no verb: still_pending) and stays listed when a status.json does not list it
1889
+ — the session is ending, it is not gone until the close says so. Matched by id: statusd lists one session under
1890
+ one id, and a closable row need not have a tmux binding. Pure; `rows` is not modified."""
1891
+ closing = {key: action['entry'] for key, action in self.actions.items() if action['verb'] == 'close'}
1892
+ if not closing:
1893
+ return rows
1894
+ out = [dict(row, pending='close') if row.get('id') in closing else row for row in rows]
1895
+ listed = {row.get('id') for row in rows}
1896
+ return out + [dict(entry, pending='close') for key, entry in closing.items() if key not in listed]
1897
+
1898
+ def closed(self, action, ok, value):
1899
+ """A close answered: the row goes (statusd's next listing agrees) and the footer says ` Closed name`; a refusal
1900
+ or a failure puts the row back as statusd lists it, every verb with it, and says why. The viewports close_lane
1901
+ emptied stay empty on a close; on a refusal each one that still shows nothing gets the session back — before S9 a
1902
+ refusal never touched the view — and one the person pointed elsewhere meanwhile keeps what they chose."""
1903
+ entry = action['entry']
1904
+ if not ok:
1905
+ self.give_back(action.get('emptied', ()), entry['id'])
1906
+ self.message = failure_text(value)
1907
+ return
1908
+ self.unregistered = [e for e in self.unregistered if e['id'] != entry['id']]
1909
+ self.status.pop(entry['id'], None)
1910
+ self.message = 'Closed ' + entry['name']
1911
+
1912
+ def promoted(self, action, ok, value):
1913
+ """`P`'s write answered. Done: the held row (hold_promoted, at the Enter) learns the id sync is expected to give
1914
+ the lane and the handle of the sync the job started — S8's watch (settle_pending, expire_pending) takes it from
1915
+ there; a hold the registry already settled meanwhile needs neither. Refused or failed: the hold goes — the row is
1916
+ statusd's again, its name as listed, every verb back — and the footer says why (` Already declared as …`)."""
1917
+ old = action['entry']['id']
1918
+ if not ok:
1919
+ self.pending = {k: v for k, v in self.pending.items() if k != old}
1920
+ self.pending_rows = {k: v for k, v in self.pending_rows.items() if k != old}
1921
+ self.message = failure_text(value)
1922
+ return
1923
+ new_id, sync = value
1924
+ if old in self.pending_rows:
1925
+ self.pending = {**self.pending, old: new_id}
1926
+ self.pending_rows = {**self.pending_rows, old: dict(self.pending_rows[old], sync=sync)}
1927
+
1928
+ def shell_created(self, action, ok, value):
1929
+ """A new shell answered. It is listed under Unregistered at once (statusd lists it on its next cycle) and opened
1930
+ in the viewport `+` asked for — the view shows it and the keyboard goes to it, as before S9 — only while nothing
1931
+ has moved on: the keyboard is still here (keys_here: Zellij's focus report, else a key within FOCUS_LEGEND_S),
1932
+ nothing is open on the sidebar, and that viewport still shows what it showed at `+`. An answer arrives on the
1933
+ tick, not on a key, so it never moves a view or the keyboard from under a person who went elsewhere; then the
1934
+ cursor, if it is still on the New shell row, goes to the shell (Enter opens it — on the New shell row it would
1935
+ make a second one). A failure says why and keeps the keyboard, as a refusal does.
1936
+
1937
+ statusd may have listed the session already (S9 fix cycle 1, DEF-1767): the tick's reload() runs before this,
1938
+ and statusd's cycle can observe a session the worker made a moment ago. Then that row IS the shell — one
1939
+ session, one row (listed_row) — and its observation stands; until the fix a second identical row was appended
1940
+ and the observation overwritten with `quiet` until statusd's next rewrite."""
1941
+ if not ok:
1942
+ self.message = failure_text(value)
1943
+ return
1944
+ entry = self.listed_row(value)
1945
+ if entry is None:
1946
+ entry = value
1947
+ self.unregistered = self.unregistered + [entry]
1948
+ self.status.setdefault(entry['id'], {'state': 'quiet'})
1949
+ idle = not (self.asking or self.filtering or self.armed or self.showing_keys)
1950
+ if idle and self.keys_here() and self.slot_target(action['slot']) == action['shown']:
1951
+ self.open_lane(entry, action['slot'])
1952
+ elif self.cursor_id == self.workspace + '--new-shell':
1953
+ self.resolve_cursor(self.visible(), entry['id'])
1954
+ self.message = 'Created ' + entry['name']
1955
+
1956
+ def listed_row(self, entry):
1957
+ """The row statusd's listing (self.unregistered) already has for `entry`'s session, or None. A tmux row is matched
1958
+ by its binding, as keep_moved_row and promoted_rows match; any other by id. statusd and new_shell name a session
1959
+ alike (session_actions.make_entry: the id is the binding's digest), so either way it is one session."""
1960
+ for row in self.unregistered:
1961
+ if row.get('id') == entry.get('id') or (
1962
+ R.eligible(entry) and R.eligible(row) and R.key(row) == R.key(entry)):
1963
+ return row
1964
+ return None
1965
+
1966
+ # ------------------------------------------------------------- the prompt
1967
+ def open_prompt(self, verb, entry, stage, value):
1968
+ """Open THE prompt (cockpit v3 S6): one footer question at a time — a new one replaces any other — answered by
1969
+ handle_keys() (`name`: typed, ⏎ accepts; `confirm`: `y` arms, ⏎ acts, any other key cancels) and drawn by
1970
+ footer_text() on the desktop and the phone alike."""
1971
+ self.prompt = {'verb': verb, 'entry': entry, 'stage': stage, 'value': value, 'armed': False}
1070
1972
  self.dirty = True
1071
1973
 
1974
+ def close_prompt(self):
1975
+ """End the prompt -> the one that was open ({} when none was): its caller acts on it, or says why not."""
1976
+ prompt, self.prompt, self.dirty = self.prompt or {}, None, True
1977
+ return prompt
1978
+
1979
+ def cancel_prompt(self):
1980
+ """End the prompt unanswered: the footer says ` Cancelled` (an Esc, any other key at a confirmation, an emptied
1981
+ name, a sidebar-key chord)."""
1982
+ self.close_prompt()
1983
+ self.message = 'Cancelled'
1984
+
1072
1985
  def request_close(self, entry):
1073
- """`x` or a click on `×`: ask first (spec §4.6(a)); handle() closes on `y` and cancels on anything else."""
1986
+ """`x` or a click on `×`: ask first (spec §4.6(a)); handle() arms on `y`, closes on the Enter after it and cancels
1987
+ on anything else (cockpit v3 S3). A lane that cannot be closed asks nothing and keeps the keyboard: the `y` typed
1988
+ next must land here, not in the lane (an agent's approval prompt). A registered lane — added with `a`, or
1989
+ declared — says what comes first (cockpit v3 S7; until then `x` there was a silent no-op)."""
1990
+ self.begin_action()
1991
+ if self.still_pending(entry):
1992
+ return
1074
1993
  if entry.get('closable'):
1075
- self.confirming, self.dirty = entry, True
1994
+ self.open_prompt('close', entry, 'confirm', entry.get('name', ''))
1995
+ elif entry.get('registered_session') or A.declared_in(entry):
1996
+ self.message, self.dirty = 'registered: u to unregister first', True
1997
+
1998
+ def still_pending(self, entry):
1999
+ """A row an action is on takes no other verb until it has answered (cockpit v3 S9 generalised S8's rule): the row
2000
+ `x` is closing (`pending: 'close'`), the session `P` is registering (`'promote'`, until the registry lists its
2001
+ lane: a close, an add, a rename, a second promote or an unregister would act on a session terminals.json already
2002
+ declares — an add would register its binding twice, DEF-1555's shape), the New shell row while a shell is on its
2003
+ way (`'new-shell'`). Says so and keeps the keyboard, as every refusal does -> True when refused. Opening a row
2004
+ being registered is no verb (the session is the same one); a row being closed refuses that too (open_lane)."""
2005
+ verb = entry.get('pending')
2006
+ if not verb:
2007
+ return False
2008
+ g = self.glyphs['marks']
2009
+ what = 'a shell' if verb == 'new-shell' else f'{g["quote-open"]}{entry.get("name", "")}{g["quote-close"]}'
2010
+ self.message = f'{PENDING_WORDS.get(verb, PENDING_WORDS["promote"])} {what} {g["dash"]} a moment'
2011
+ self.dirty = True
2012
+ return True
1076
2013
 
1077
2014
  def close_lane(self, entry):
2015
+ """The armed confirmation's Enter: the close runs on the worker (cockpit v3 S9; until S9 it held the frame through
2016
+ the registry lock and three tmux answers) and the keyboard goes back to the lane at once — the action has ended
2017
+ for the keyboard (S3). The row stays, `pending`, until closed() takes the answer.
2018
+
2019
+ The session lives until the worker's kill, up to a 5 s lock wait and three 5 s tmux answers later, so every
2020
+ viewport of this view that shows it is emptied FIRST (empty_views; S9 fix cycle 1, DEF-1767): the keyboard then
2021
+ lands on view.sh's `No lane selected` screen, which discards keys, never in the shell being killed. Before S9,
2022
+ close_session's clear_viewers emptied the view before release_focus; it still empties other devices' views after
2023
+ the kill. closed() gives an emptied viewport the session back if the close is refused."""
1078
2024
  if not entry.get('closable'):
1079
2025
  return
1080
- self.message = 'Closing ' + entry['name'] + '…'
1081
- self.render()
1082
2026
  try:
1083
- A.close_session(self.workspace, entry)
1084
- self.unregistered = [e for e in self.unregistered if e['id'] != entry['id']]
1085
- self.status.pop(entry['id'], None)
1086
- self.message = 'Closed ' + entry['name']
1087
- except (L.LanesError, OSError, ValueError) as error:
1088
- self.message = str(error)
1089
- self.dirty = True
2027
+ emptied = self.empty_views(entry['id'])
2028
+ except OSError as error: # a view still on the session would take the keys: close nothing, keep the keyboard
2029
+ self.message = f'Could not clear the view: {error.strerror or failure_text(error)}; nothing was closed'
2030
+ self.dirty = True
2031
+ return
2032
+ g = self.glyphs['marks']
2033
+ self.message = f'Closing {g["quote-open"]}{entry["name"]}{g["quote-close"]}'
2034
+ self.start_action(entry['id'], {'verb': 'close', 'entry': entry, 'emptied': emptied},
2035
+ A.close_session, self.workspace, entry)
2036
+ self.release_focus()
2037
+
2038
+ def empty_views(self, lane_id):
2039
+ """Point every viewport of this view that shows `lane_id` at nothing (EMPTY_VIEW; lanes_lib.show_lane writes the
2040
+ target and wakes its view.sh, as an open does) -> the slots emptied, for closed() to give back on a refusal.
2041
+ A viewport showing another lane is left alone. A target that cannot be written (a full disk) raises OSError
2042
+ after the slots already emptied are given back: close_lane then closes nothing."""
2043
+ emptied = []
2044
+ try:
2045
+ for slot in SLOTS[:self.viewports]:
2046
+ if self.slot_target(slot) == lane_id:
2047
+ L.show_lane(self.view_dir, self.workspace, slot, EMPTY_VIEW)
2048
+ emptied.append(slot)
2049
+ except OSError:
2050
+ self.give_back(emptied, lane_id)
2051
+ raise
2052
+ return tuple(emptied)
2053
+
2054
+ def give_back(self, slots, lane_id):
2055
+ """Point each of `slots` that still shows nothing (EMPTY_VIEW) back at `lane_id`; one pointed elsewhere since
2056
+ keeps the person's choice. Runs on run()'s tick (closed()), so a target that cannot be written is no crash: that
2057
+ view stays on `No lane selected`, and Enter on the row opens it again."""
2058
+ for slot in slots:
2059
+ if self.slot_target(slot) == EMPTY_VIEW:
2060
+ try:
2061
+ L.show_lane(self.view_dir, self.workspace, slot, lane_id)
2062
+ except OSError:
2063
+ pass
1090
2064
 
1091
2065
  def start_rename(self, entry):
1092
- """Begin inline rename of the lane under the cursor."""
1093
- if entry.get('divider') or entry.get('action'):
2066
+ """Begin inline rename of the lane under the cursor: the `rename` prompt's name field, prefilled."""
2067
+ self.begin_action()
2068
+ if entry.get('divider') or entry.get('action') or self.still_pending(entry):
1094
2069
  return
1095
- self.renaming = True
1096
- self.rename_entry = entry
1097
- self.rename_value = entry.get('name', '')
1098
- self.dirty = True
2070
+ self.open_prompt('rename', entry, 'name', entry.get('name', ''))
1099
2071
 
1100
2072
  def commit_rename(self):
1101
- """Confirm the in-progress rename."""
1102
- entry = self.rename_entry
1103
- new_name = self.rename_value.strip()
1104
- self.renaming, self.rename_entry, self.rename_value = False, None, ''
1105
- if not new_name or not entry:
1106
- self.dirty = True
2073
+ """Confirm the in-progress rename; the keyboard then goes back to the lane (cockpit v3 S3). An emptied name, or
2074
+ the name the lane already has, changes nothing and says so (cockpit v3 S8: until then a silent no-op)."""
2075
+ prompt = self.close_prompt()
2076
+ entry, new_name = prompt.get('entry'), prompt.get('value', '').strip()
2077
+ if not entry:
2078
+ self.release_focus()
2079
+ return
2080
+ if not new_name or new_name == entry.get('name'):
2081
+ self.message = 'name unchanged'
2082
+ self.release_focus()
1107
2083
  return
1108
2084
  try:
1109
2085
  # Routed by the registry SHAPE, never by the id's spelling: an adopted session keeps its `--x-` id
1110
2086
  # but its name authority is registered-sessions.json, not user-sessions/ (DEF-1556).
1111
2087
  if entry.get('registered_session'):
1112
- R.rename(entry['id'], new_name)
1113
- self.message = f'Renamed → {new_name}'
2088
+ try:
2089
+ R.rename(entry['id'], new_name)
2090
+ self.message = f'Renamed → {new_name}'
2091
+ except L.ProjectionRefused as error: # saved and published; a container refused its own copy
2092
+ self.message = f'Renamed → {new_name}; {error.pending}'
1114
2093
  self.reload(force=True)
1115
2094
  elif entry.get('closable'):
1116
2095
  # Unregistered session
@@ -1122,10 +2101,12 @@ class Sidebar:
1122
2101
  else:
1123
2102
  # Registered lane: a repositories[i].displayNames override in the board manifest + sync.
1124
2103
  # The lane id never changes, so the cursor, view targets and `lane open <id>` keep working.
2104
+ # `declared_display` (the container's terminals.json name, LNS-33) is what "back to the default" means.
1125
2105
  registry_name = entry.get('registry_name') or entry.get('name', '')
1126
2106
  if not entry.get('repo'):
1127
2107
  raise L.LanesError('No repository for this lane; edit the workspace manifest directly')
1128
- A.rename_registered(L.manifest_path(self.workspace), entry['repo'], registry_name, new_name)
2108
+ A.rename_registered(L.manifest_path(self.workspace), entry['repo'], registry_name, new_name,
2109
+ declared_display=entry.get('declared_display'))
1129
2110
  # Trigger a registry rebuild
1130
2111
  import subprocess as _sp
1131
2112
  _sp.Popen([os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
@@ -1135,10 +2116,14 @@ class Sidebar:
1135
2116
  except (L.LanesError, OSError, ValueError) as error:
1136
2117
  self.message = str(error)
1137
2118
  self.dirty = True
2119
+ self.release_focus()
1138
2120
 
1139
2121
  def start_promote(self, entry):
1140
- """Begin promotion confirmation for an unregistered session."""
1141
- if entry.get('divider') or entry.get('action'):
2122
+ """Begin promotion of an unregistered session: the `promote` prompt's name stage, prefilled with the session's
2123
+ name (Enter accepts it), then its confirmation — `y` arms, Enter registers (cockpit v3 S3). A refusal asks
2124
+ nothing and keeps the keyboard, as `x` on a lane that cannot be closed does."""
2125
+ self.begin_action()
2126
+ if entry.get('divider') or entry.get('action') or self.still_pending(entry):
1142
2127
  return
1143
2128
  # Gated the way `start_registration` is: only an UNREGISTERED, reconnectable tmux session. A repository
1144
2129
  # lane or an adopted (`registered_session`) lane is already registered; promoting it would append a
@@ -1151,34 +2136,249 @@ class Sidebar:
1151
2136
  self.message = 'Only unregistered tmux sessions can be promoted'
1152
2137
  self.dirty = True
1153
2138
  return
1154
- self.promoting = True
1155
- self.promote_entry = entry
2139
+ self.open_prompt('promote', entry, 'name', entry.get('name', ''))
2140
+
2141
+ def accept_promote_name(self):
2142
+ """Enter on the name stage: an empty name registers nothing (cancelled); otherwise the confirmation."""
2143
+ name = self.prompt['value'].strip()
2144
+ if not name:
2145
+ self.cancel_prompt()
2146
+ self.release_focus()
2147
+ return
2148
+ self.prompt.update(stage='confirm', value=name, armed=False)
1156
2149
  self.dirty = True
1157
2150
 
1158
2151
  def commit_promote(self):
1159
- """Confirm the in-progress promotion (caller has already checked 'y')."""
1160
- entry = self.promote_entry
1161
- self.promoting, self.promote_entry = False, None
2152
+ """Register the session under the accepted name (the caller has seen `y` then Enter); the keyboard then goes back
2153
+ to the lane. The name is the terminal's title in terminals.json, so it is checked as every lane name is, here,
2154
+ before anything is written, and it reaches the writer as `name=` (cockpit v3 S8). The write and the `lanes sync`
2155
+ it starts run on the worker (promote_job, cockpit v3 S9), and the row is held from this Enter on: it STAYS where
2156
+ it is, under its old id, with the new name and the `pending` mark (hold_promoted) — promoted() then gives the
2157
+ hold the expected id and the sync's handle (expire_pending never lets go while that job or that sync runs), or
2158
+ lets it go with the writer's refusal — until the registry lists its lane, where settle_pending moves the cursor
2159
+ onto it. A sync that cannot be started holds the row all the same: terminals.json already declares the session,
2160
+ and statusd's hot sync starts one on the change. No reload: until S8 the row was dropped and a forced reload read
2161
+ the status.json statusd had written before the sync, which put it back under its old name before the sync moved
2162
+ it (DEF-1827)."""
2163
+ prompt = self.close_prompt()
2164
+ entry, name = prompt.get('entry'), prompt.get('value', '')
1162
2165
  if not entry:
1163
- self.dirty = True
2166
+ self.release_focus()
1164
2167
  return
1165
2168
  try:
1166
- registry = read_json(self.registry_path) or {}
1167
- A.promote_to_registered(self.workspace, entry, registry)
1168
- # Trigger a registry rebuild so the lane moves from unregistered → registered
1169
- import subprocess as _sp
1170
- _sp.Popen([os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
1171
- 'bin', 'lanes'), 'sync'],
1172
- stdout=_sp.DEVNULL, stderr=_sp.DEVNULL)
1173
- self.unregistered = [e for e in self.unregistered if e['id'] != entry['id']]
1174
- self.message = f'Promoted — reload in a moment'
1175
- self.reload(force=True)
1176
- except (L.LanesError, OSError, ValueError) as error:
1177
- self.message = str(error)
2169
+ L._title(name)
2170
+ except L.LanesError as error:
2171
+ self.message, self.dirty = str(error), True
2172
+ self.release_focus()
2173
+ return
2174
+ self.hold_promoted(entry, name, None)
2175
+ self.start_action(entry['id'], {'verb': 'promote', 'entry': entry, 'name': name}, promote_job,
2176
+ A.promote_to_registered, subprocess.Popen, self.workspace, entry, self.registry_path, name)
2177
+ g = self.glyphs['marks']
2178
+ self.message = f'Registering {g["quote-open"]}{name}{g["quote-close"]}'
2179
+ self.release_focus()
2180
+
2181
+ # ------------------------------------------------------- a promotion on its way (cockpit v3 S8)
2182
+ def hold_promoted(self, entry, name, new_id, sync=None):
2183
+ """Keep the promoted session's row (promoted_rows) until the registry lists its lane (settle_pending) or
2184
+ PENDING_S passes with no job or sync of ours running (expire_pending). `new_id` is the id lanes sync is expected
2185
+ to give it (None: not known — commit_promote holds the row before the worker's write says it, promoted() fills it
2186
+ in); `sync` the `lanes sync` this sidebar started for it (None: none started yet, or none could be)."""
2187
+ row = dict(entry, name=name, pending='promote')
2188
+ self.pending = {**self.pending, entry['id']: new_id}
2189
+ self.pending_rows = {**self.pending_rows, entry['id']: {'row': row, 'at': time.monotonic(), 'sync': sync}}
2190
+
2191
+ def promoted_rows(self, rows):
2192
+ """statusd's unregistered `rows` as this sidebar shows them while a promotion is on its way: the session's row
2193
+ carries its new name and `pending: 'promote'` (column 3's mark; no verb but open, still_pending), and it stays
2194
+ listed when a status.json does not list it (a probe cut at statusd's deadline) — the session is listed exactly
2195
+ once from `P` until the registry lists its lane, where _visible() hides the row (its binding is registered).
2196
+ Matched by binding, as keep_moved_row matches. Pure; `rows` is not modified."""
2197
+ if not self.pending_rows:
2198
+ return rows
2199
+ held = {R.key(h['row']): h['row'] for h in self.pending_rows.values()}
2200
+ out, seen = [], set()
2201
+ for row in rows:
2202
+ mine = held.get(R.key(row)) if R.eligible(row) else None
2203
+ if mine is None:
2204
+ out.append(row)
2205
+ else:
2206
+ out.append(dict(row, name=mine['name'], pending='promote'))
2207
+ seen.add(R.key(row))
2208
+ return out + [row for key, row in held.items() if key not in seen]
2209
+
2210
+ def settle_pending(self, cursor_id):
2211
+ """reload(): a promotion whose lane the registry now lists is done -> `cursor_id`, mapped onto that lane when it
2212
+ was the promoted row: the cursor lands on the new id. The lane is the one holding the session's binding —
2213
+ `self.pending`'s id when the registry lists it for that binding, else whichever id it lists for it. The binding
2214
+ decides because the predicted id is only a prediction: sync suffixes an id the board already has (`-2`), and
2215
+ then the predicted id is ANOTHER lane's, which the cursor must never land on."""
2216
+ if not self.pending:
2217
+ return cursor_id
2218
+ lanes = [e for e in self.entries if e.get('holder') == 'tmux' and e.get('id')]
2219
+ by_key = {}
2220
+ for lane in lanes:
2221
+ by_key.setdefault(R.key(lane), lane['id'])
2222
+ by_id = {lane['id']: R.key(lane) for lane in lanes}
2223
+ pending, rows = dict(self.pending), dict(self.pending_rows)
2224
+ for old_id, new_id in self.pending.items():
2225
+ key = R.key(rows[old_id]['row'])
2226
+ lane_id = new_id if new_id and by_id.get(new_id) == key else by_key.get(key)
2227
+ if lane_id is None:
2228
+ continue
2229
+ del pending[old_id], rows[old_id]
2230
+ if cursor_id == old_id:
2231
+ cursor_id = lane_id
2232
+ self.pending, self.pending_rows = pending, rows
2233
+ return cursor_id
2234
+
2235
+ def expire_pending(self, now=None):
2236
+ """run()'s tick: a promotion the registry has not listed PENDING_S after `P` lets go — the row is statusd's again
2237
+ (its name as listed, no mark, every verb back) and the footer says the registry has not listed it; a failed sync
2238
+ shows its own ` ⚠ registry: …` line. Never while the `lanes sync` this sidebar started for it still runs (S8 fix
2239
+ cycle 1, DEF-1762): that is a sync on its way, and the footer would send the operator to start another. Nor while
2240
+ the worker has not answered its write (cockpit v3 S9: a job queued behind a slow close). Each tick `poll()`s the
2241
+ sync, which also reaps one that has ended. -> True when one went (one repaint)."""
2242
+ now = time.monotonic() if now is None else now
2243
+
2244
+ def syncing(old, held):
2245
+ sync = held.get('sync')
2246
+ return old in self.actions or (sync is not None and sync.poll() is None)
2247
+
2248
+ gone = [old for old, held in self.pending_rows.items()
2249
+ if not syncing(old, held) and now - held['at'] >= PENDING_S]
2250
+ if not gone:
2251
+ return False
2252
+ g = self.glyphs['marks']
2253
+ name = self.pending_rows[gone[-1]]['row'].get('name', '')
2254
+ self.pending = {k: v for k, v in self.pending.items() if k not in gone}
2255
+ self.pending_rows = {k: v for k, v in self.pending_rows.items() if k not in gone}
2256
+ self.message = f'{g["quote-open"]}{name}{g["quote-close"]} not listed yet {g["dash"]} lanes sync'
2257
+ self.dirty = True
2258
+ return True
2259
+
2260
+ def answer_prompt(self, ch):
2261
+ """One typed key at a `confirm` stage — `close` and `promote` alike (cockpit v3 S6). `y` arms; Enter, once armed,
2262
+ acts; anything else cancels, is consumed and hands the keyboard back. An escape sequence never gets here:
2263
+ prompt_sequence() answers those, at either stage, before any (until S6 the register confirmation was read
2264
+ after the escape sequences, so an arrow or a click under it moved the cursor or opened a lane and left it
2265
+ open, DEF-1823)."""
2266
+ prompt = self.prompt
1178
2267
  self.dirty = True
2268
+ if ch in 'yY' and not prompt['armed']:
2269
+ prompt['armed'] = True
2270
+ return
2271
+ if ch in '\r\n' and prompt['armed']:
2272
+ if prompt['verb'] == 'close':
2273
+ self.close_lane(self.close_prompt()['entry']) # hands the keyboard back when it is done
2274
+ elif prompt['verb'] == 'hide':
2275
+ self.commit_hide_workspace()
2276
+ else:
2277
+ self.commit_promote()
2278
+ return
2279
+ self.cancel_prompt()
2280
+ self.release_focus()
2281
+
2282
+ def prompt_sequence(self, key):
2283
+ """ONE escape sequence (as key_length() cuts it) while the prompt is open, at either stage (cockpit v3 S6 fix
2284
+ cycle 1, DEF-1824: S6 gave a rule to the confirmation only, and under a name field → or a click on a row opened
2285
+ the lane — the keyboard went to it — and left the field open in the footer, so the next key typed on the
2286
+ sidebar went into a name nobody was editing any more). -> True when the caller handles the sequence as usual —
2287
+ the prompt cancelled first (` Cancelled`): a click on a row then opens that lane, on `×` asks to close it —;
2288
+ False when the sequence is consumed and the prompt stays.
2289
+ Both stages: what does not prove the keyboard is here (moves_focus) is no answer — a mouse release (one follows
2290
+ every press, the `×` click that opened a confirmation included), a wheel tick, motion, a report cut at the end
2291
+ of the read; the list does not scroll under a prompt. A press answers: a click is aimed at the list.
2292
+ A confirmation has no text to lose: every other sequence answers too (Esc, an arrow, Shift-Tab, PageDown).
2293
+ A name field keeps the keyboard: Enter, Space, `j` `k` `J` `K` and Tab already type into it or nothing, so
2294
+ their twins → ↑ ↓ PageUp PageDown Shift-Tab ← Home End Delete type nothing in it either — they never open a
2295
+ lane, move the cursor or throw away a half-typed name, and what is typed after them still lands in the field
2296
+ on screen. Only Esc (and Enter, or an emptied name) ends it from the keyboard. A click on the upload strip arms
2297
+ the strip beside the field, which keeps its text and the keyboard (S3 review: the armed strip takes the next
2298
+ paste as the drop, then Enter still saves the name)."""
2299
+ if not moves_focus(key):
2300
+ return False
2301
+ if self.prompt['stage'] == 'name':
2302
+ if not key.startswith('\033[<'):
2303
+ if key != '\033':
2304
+ return False
2305
+ elif self.strip_press(key):
2306
+ return True
2307
+ self.cancel_prompt()
2308
+ return True
2309
+
2310
+ def strip_press(self, key):
2311
+ """Whether ONE SGR mouse report is the press handle_keys arms the upload strip with: the left button, at or below
2312
+ `box_top`, while the strip is drawn (`box`)."""
2313
+ try:
2314
+ button, _x, y = (int(v) for v in key[3:-1].split(';'))
2315
+ except ValueError:
2316
+ return False
2317
+ return key[-1:] == 'M' and button == 0 and bool(self.box) and y >= self.box_top
2318
+
2319
+ # ------------------------------------------------- the prompt, S3's names (compat)
2320
+ # Read-only views of the one prompt object under the field names S3 used, so code and tests written against them
2321
+ # keep reading the same truth; `rename_value` / `promote_value` may also be assigned while their prompt is open.
2322
+ def prompt_for(self, verb):
2323
+ """The open prompt when its verb is `verb`, else None."""
2324
+ return self.prompt if self.prompt and self.prompt['verb'] == verb else None
2325
+
2326
+ @property
2327
+ def confirming(self):
2328
+ """The entry `x` / `×` asked to close, or None."""
2329
+ return (self.prompt_for('close') or {}).get('entry')
2330
+
2331
+ @property
2332
+ def confirm_armed(self):
2333
+ """`y` armed the open confirmation (close or register): the footer names Enter."""
2334
+ return bool(self.prompt and self.prompt['armed'])
2335
+
2336
+ @property
2337
+ def renaming(self):
2338
+ return self.prompt_for('rename') is not None
2339
+
2340
+ @property
2341
+ def rename_entry(self):
2342
+ return (self.prompt_for('rename') or {}).get('entry')
2343
+
2344
+ @property
2345
+ def rename_value(self):
2346
+ return (self.prompt_for('rename') or {}).get('value', '')
2347
+
2348
+ @rename_value.setter
2349
+ def rename_value(self, value):
2350
+ self.field_of('rename')['value'] = value
2351
+
2352
+ @property
2353
+ def promoting(self):
2354
+ return self.prompt_for('promote') is not None
2355
+
2356
+ @property
2357
+ def promote_entry(self):
2358
+ return (self.prompt_for('promote') or {}).get('entry')
2359
+
2360
+ @property
2361
+ def promote_stage(self):
2362
+ return (self.prompt_for('promote') or {}).get('stage', 'name')
2363
+
2364
+ @property
2365
+ def promote_value(self):
2366
+ return (self.prompt_for('promote') or {}).get('value', '')
2367
+
2368
+ @promote_value.setter
2369
+ def promote_value(self, value):
2370
+ self.field_of('promote')['value'] = value
2371
+
2372
+ def field_of(self, verb):
2373
+ """The open `verb` prompt, to be written: assigning a value with no such prompt open is a bug, never a no-op."""
2374
+ prompt = self.prompt_for(verb)
2375
+ if prompt is None:
2376
+ raise AttributeError(f'no {verb} prompt is open')
2377
+ return prompt
1179
2378
 
1180
2379
  def start_registration(self, entry):
1181
- if not entry.get('closable') or not R.eligible(entry):
2380
+ self.begin_action()
2381
+ if self.still_pending(entry) or not entry.get('closable') or not R.eligible(entry):
1182
2382
  return
1183
2383
  choices = R.destinations({'workspaces': self.workspaces}, entry)
1184
2384
  choices.sort(key=lambda w: w['id'] != self.workspace)
@@ -1186,32 +2386,280 @@ class Sidebar:
1186
2386
  self.message, self.dirty = 'No workspace uses this session’s container', True
1187
2387
  return
1188
2388
  self.registration = entry
1189
- self.message = ''
2389
+ self.registration_return = (self.cursor, self.top) # where cancel_registration puts the list back (S8)
1190
2390
  self.armed = False
1191
2391
  self.registration_choices = [{'id': w['id'], 'workspace': w['id'], 'name': w.get('title') or w['id'],
1192
2392
  'action': 'register-workspace'} for w in choices]
1193
2393
  self.filter, self.filtering, self.top, self.dirty = '', False, 0, True
1194
2394
  self.set_cursor(0)
1195
2395
 
1196
- def cancel_registration(self):
2396
+ def cancel_registration(self, cursor_id=None):
2397
+ """Close the Add to workspace picker and put the list back as it was (cockpit v3 S8): `top` where it was, the
2398
+ cursor on `cursor_id` — the lane `a` just registered — else on the session the picker was opened for (Esc,
2399
+ Cancel, a sidebar-key chord), wherever its row now is; on the row it had when neither is listed (the session
2400
+ went to another workspace). Until S8 the cursor went to row 0 — the first lane — every time, and the `x` or `R`
2401
+ typed next acted on a lane nobody chose (DEF-1827)."""
2402
+ entry = self.registration or {}
2403
+ self.cursor, self.top = self.registration_return
1197
2404
  self.registration, self.registration_choices = None, []
1198
- self.top, self.dirty = 0, True
1199
- self.set_cursor(0)
2405
+ self.dirty = True
2406
+ self.resolve_cursor(self.visible(), cursor_id or entry.get('id'))
1200
2407
 
1201
2408
  def register_lane(self, workspace):
1202
2409
  entry = self.registration
1203
2410
  if not entry:
1204
2411
  return
1205
2412
  try:
1206
- lane = R.register(self.workspace, entry, workspace)
2413
+ pending = ''
2414
+ try:
2415
+ lane = R.register(self.workspace, entry, workspace)
2416
+ except L.ProjectionRefused as error:
2417
+ # Saved and in registry.json, so the lane IS added: finish like a success (the picker closes; a
2418
+ # second Enter would only say "Already registered"), but say which container's `lane open` still
2419
+ # reads the old projection (DEF-1796) instead of leaving that to the timer sync's journal.
2420
+ lane, pending = error.result, '; ' + error.pending
1207
2421
  self.unregistered = [e for e in self.unregistered if e['id'] != lane['id']]
1208
2422
  title = next((w.get('title') or w['id'] for w in self.workspaces if w['id'] == workspace), workspace)
1209
- self.cancel_registration()
1210
- self.reload(force=True)
1211
- self.message = 'Added to ' + title
2423
+ self.reload(force=True) # R.register published registry.json: the lane is listed now
2424
+ self.cancel_registration(lane['id']) # and the cursor goes with the session into the list (S8)
2425
+ self.message = 'Added to ' + title + pending
1212
2426
  except (L.LanesError, OSError, ValueError) as error:
1213
2427
  self.message = str(error)
1214
2428
  self.dirty = True
2429
+ self.release_focus() # a refusal keeps the picker open, and the picker keeps the keyboard
2430
+
2431
+ # ------------------------------------------------------- pins and hidden lanes (cockpit v3 R5)
2432
+ def write_arrangement(self, change):
2433
+ """Write `change` into the owner's profile (users.update as this device, waiting at most PROFILE_LOCK_S) and
2434
+ take the stored arrangement at once — the list changes on this key, not at the next tick (whose reload reads the
2435
+ same profile again). -> True when the profile holds it; False (said in the footer) when the lock was busy or the
2436
+ profile is not one this release writes. An L.LanesError `change` raises (a refusal) propagates, nothing written."""
2437
+ directory = os.path.dirname(self.profile_path)
2438
+ if not users.update(self.login, self.device, change, directory=directory, timeout=PROFILE_LOCK_S):
2439
+ self.message = f'profile busy or unreadable {self.glyphs["marks"]["sep"]} try again'
2440
+ return False
2441
+ self.take_arrangement(users.read(self.login, directory))
2442
+ self.fleet = self.fleet_counts()
2443
+ self.dirty = True
2444
+ return True
2445
+
2446
+ def arrangeable(self, entry, verb):
2447
+ """Whether `p` / `h` may act on `entry`: a registry lane of this workspace, on a device with an owner, no action
2448
+ on its way. Says why not, keeping the keyboard, as every refusal does."""
2449
+ if entry.get('divider') or entry.get('action') or self.still_pending(entry):
2450
+ return False
2451
+ if not self.registry_lane(entry):
2452
+ self.message = f'only a registered lane {verb}'
2453
+ return False
2454
+ if not self.profile_path:
2455
+ self.message = NO_PROFILE
2456
+ return False
2457
+ return True
2458
+
2459
+ def pin_lane(self, entry):
2460
+ """`p`: pin the cursor lane — it leads its section with the ▲ mark on every device of the owner — or unpin it.
2461
+ The cursor goes with the lane; then the keyboard goes back to the lane (cockpit v3 S3)."""
2462
+ self.begin_action()
2463
+ self.dirty = True
2464
+ if not self.arrangeable(entry, 'pins'):
2465
+ return
2466
+ lane_id, pinned = entry['id'], entry['id'] in self.lane_pins
2467
+
2468
+ def change(profile):
2469
+ pins = [ident for ident in profile['lanes']['pins'] if ident != lane_id]
2470
+ profile['lanes']['pins'] = pins if pinned else pins + [lane_id]
2471
+ if self.write_arrangement(change):
2472
+ g = self.glyphs['marks']
2473
+ self.message = f'{"Unpinned" if pinned else "Pinned"} {g["quote-open"]}{entry.get("name", lane_id)}{g["quote-close"]}'
2474
+ self.resolve_cursor(self.visible(), lane_id)
2475
+ self.release_focus()
2476
+
2477
+ def hide_lane(self, entry):
2478
+ """`h`: hide the cursor lane on every device of the owner — it leaves the list, the counts and the notifications,
2479
+ the cursor stays on its row (the next lane) — or, while `o` shows the hidden lanes, show a hidden one again. A
2480
+ session listed under Unregistered is never hidden (nothing alive is), a divider neither."""
2481
+ self.begin_action()
2482
+ self.dirty = True
2483
+ if not self.arrangeable(entry, 'hides'):
2484
+ return
2485
+ lane_id, hidden = entry['id'], entry['id'] in self.lane_hidden
2486
+
2487
+ def change(profile):
2488
+ rest = [ident for ident in profile['lanes']['hidden'] if ident != lane_id]
2489
+ profile['lanes']['hidden'] = rest if hidden else rest + [lane_id]
2490
+ if self.write_arrangement(change):
2491
+ g = self.glyphs['marks']
2492
+ name = f'{g["quote-open"]}{entry.get("name", lane_id)}{g["quote-close"]}'
2493
+ self.message = f'Shown {name}' if hidden else f'Hid {name} {g["sep"]} o shows it'
2494
+ self.release_focus()
2495
+
2496
+ def toggle_hidden(self):
2497
+ """`o`: show the lanes the owner hid (faint, in their places; `h` there shows one for good) or hide them again.
2498
+ The footer says how many; then the keyboard goes back to the lane."""
2499
+ self.begin_action()
2500
+ self.show_hidden, self.dirty = not self.show_hidden, True
2501
+ count = sum(1 for entry in self.entries if entry.get('id') in self.lane_hidden)
2502
+ g = self.glyphs['marks']
2503
+ if not count:
2504
+ self.message = 'no hidden lanes'
2505
+ elif self.show_hidden:
2506
+ self.message = f'showing {count} hidden {g["sep"]} o hides {"it" if count == 1 else "them"}'
2507
+ else:
2508
+ self.message = f'{count} hidden {g["sep"]} o shows {"it" if count == 1 else "them"}'
2509
+ self.release_focus()
2510
+
2511
+ def request_hide_workspace(self):
2512
+ """`H`: ask to hide this workspace for the owner (` › hide ‘title’ — y⏎ confirm`, the one prompt); `y` and Enter
2513
+ hide it (commit_hide_workspace). Refused, keeping the keyboard, on a device nobody owns and for the last
2514
+ workspace the person still sees (users.arrange never leaves a view without one)."""
2515
+ self.begin_action()
2516
+ self.dirty = True
2517
+ if not self.profile_path:
2518
+ self.message = NO_PROFILE
2519
+ return
2520
+ shown = [board.get('id') for board in users.arrange(self.workspaces, {'workspaces': self.profile_workspaces})]
2521
+ if shown == [self.workspace] or not shown:
2522
+ self.message = 'the last workspace shown stays'
2523
+ return
2524
+ self.open_prompt('hide', {'id': self.workspace, 'name': self.title}, 'confirm', self.title)
2525
+
2526
+ def commit_hide_workspace(self):
2527
+ """The armed `H`'s Enter: add this workspace to the profile's `workspaces.hidden` (refused when no other board
2528
+ would be left — the profile may have changed since `H`), then start `lanes upgrade-session` in its own session:
2529
+ it closes this workspace's tab on every live view of the owner (device_sync.reconcile, R4) — this pane's tab
2530
+ included, which is why the child must not die with it. The lanes keep running in their containers."""
2531
+ prompt, workspace = self.close_prompt(), self.workspace
2532
+ boards = [board.get('id') for board in self.workspaces]
2533
+
2534
+ def change(profile):
2535
+ hidden = [ident for ident in profile['workspaces']['hidden'] if ident != workspace] + [workspace]
2536
+ if not [ident for ident in boards if ident not in hidden]:
2537
+ raise L.LanesError('the last workspace shown stays')
2538
+ profile['workspaces']['hidden'] = hidden
2539
+ try:
2540
+ written = self.write_arrangement(change)
2541
+ except L.LanesError as error:
2542
+ self.message, written = str(error), False
2543
+ if written:
2544
+ g = self.glyphs['marks']
2545
+ self.message = f'Hiding workspace {g["quote-open"]}{prompt.get("value", workspace)}{g["quote-close"]}'
2546
+ try:
2547
+ subprocess.Popen([LANES_BIN, 'upgrade-session'], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
2548
+ stderr=subprocess.DEVNULL, start_new_session=True)
2549
+ except OSError:
2550
+ pass # the next `lanes sync` (2 min) or `lanes ensure` (1 min) closes the tab instead
2551
+ self.release_focus()
2552
+
2553
+ # ------------------------------------------------------- unregister and its undo (cockpit v3 S7)
2554
+ def unregister_lane(self, entry):
2555
+ """`u`: take a session added with `a` (`registered_session`) out of registered-sessions.json at once — no
2556
+ confirmation, `z` undoes it for UNDO_S. registrations.unregister publishes the registry without it; the row is
2557
+ put back under Unregistered with the SAME id (keep_moved_row), so the cursor that was on the lane follows it
2558
+ there, and statusd's next cycle lists the session under that id. A session that no longer runs leaves the list
2559
+ (nothing will be listed) and the cursor keeps its row. Then the keyboard goes back to the lane, as after every
2560
+ action that ends (S3) — `z` is pressed on the sidebar (Ctrl-Alt-l; on a phone the picker keeps the keyboard).
2561
+ A lane a repository's terminals.json declares is taken out of that file (session_actions.unregister_lane, the
2562
+ Agent API's minimal-text editor, M4 — wired at the fan-in): its terminal goes, the session keeps running and
2563
+ `lanes sync`, started here, lists it under Unregistered; there is no `z` for it (the file's
2564
+ `<file>.bak-<UTC>` keeps what was there). Refused with a word, keeping the keyboard: the editor's refusals
2565
+ (the last terminal, an adopted lane, a link at or above the file, a file that is gone …), a lane of an SSH
2566
+ workspace (catalog.json, which no editor of ours writes: `declared in … — edit the file`), an unregistered
2567
+ session (`a` adds it), and tmux giving no usable answer about the session. The undo offer lives in
2568
+ `self.undo`, never in the message: the footer draws it below a message. A record out of the file whose hand-back
2569
+ or publication then failed (`R.Unpublished`) keeps its undo: the message says `lanes sync` finishes it, and `z`
2570
+ puts the record back and publishes (fix cycle 1, DEF-1826)."""
2571
+ self.begin_action()
2572
+ if entry.get('divider') or entry.get('action') or self.still_pending(entry):
2573
+ return
2574
+ self.dirty = True
2575
+ dash = self.glyphs['marks']['dash']
2576
+ if entry.get('registered_session'):
2577
+ try:
2578
+ record = R.unregister(entry['id'])
2579
+ except R.Unpublished as error: # out of the file, not yet published: the undo still stands
2580
+ record, self.message = error.record, str(error)
2581
+ except (L.LanesError, OSError, ValueError) as error:
2582
+ self.message = str(error)
2583
+ return
2584
+ self.undo = {'record': record, 'at': time.monotonic()}
2585
+ self.reload(force=True) # registry.json is published without it; keep_moved_row puts the row back
2586
+ self.resolve_cursor(self.visible(), entry['id'])
2587
+ self.release_focus()
2588
+ elif entry.get('closable'):
2589
+ self.message = f'not registered {dash} a adds it to a workspace'
2590
+ elif A.declared_in(entry):
2591
+ if not entry.get('source'): # an SSH workspace's lane: catalog.json, which no editor of ours writes
2592
+ self.message = A.declared_text(entry, dash)
2593
+ return
2594
+ try:
2595
+ done = A.unregister_lane(entry['id'])
2596
+ except (L.LanesError, OSError, ValueError) as error: # A.DeclaredRefused says why in its own words
2597
+ self.message = str(error)
2598
+ return
2599
+ if done.get('changed'):
2600
+ try: # statusd's hot-sync would start one within ~15 s; this lists it under Unregistered sooner
2601
+ subprocess.Popen([LANES_BIN, 'sync'], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
2602
+ stderr=subprocess.DEVNULL, start_new_session=True)
2603
+ except OSError:
2604
+ pass
2605
+ self.message = 'Unregistered ' + entry.get('name', entry['id'])
2606
+ self.reload(force=True)
2607
+ self.release_focus()
2608
+
2609
+ def keep_moved_row(self):
2610
+ """While `z` can still undo an unregister, the row `u` moved stays under Unregistered until statusd lists the
2611
+ session itself: a status.json from a cycle that began before the unregister would drop it for a cycle, and
2612
+ the cursor on it with it. reload() calls this after every status.json it reads. The row is
2613
+ registrations.ghost — statusd's own shape and id — so the listing replaces it without a second row. While
2614
+ registry.json still lists the lane (its publication failed, R.Unpublished) _visible() shows the lane, not this."""
2615
+ ghost = R.ghost(self.undo['record']) if self.undo else None
2616
+ if ghost and ghost['workspace'] == self.workspace and not any(
2617
+ R.key(e) == R.key(ghost) for e in self.unregistered):
2618
+ self.unregistered = self.unregistered + [ghost]
2619
+
2620
+ def undo_unregister(self):
2621
+ """`z`: within UNDO_S of `u`, register the session again — the record `u` took out, byte for byte, after the
2622
+ identity check `register` makes (registrations.reregister: a session closed and re-created in the window is
2623
+ refused). The cursor follows it back to its workspace's list and the keyboard goes back to the lane. A refusal
2624
+ says why and keeps the offer and the keyboard (a busy registry lock may let a second `z` through). A record
2625
+ saved whose publication failed (`R.Unpublished`) is the undo done: the offer goes and the message says
2626
+ `lanes sync` finishes it."""
2627
+ self.begin_action()
2628
+ self.dirty = True
2629
+ if not self.undo or time.monotonic() - self.undo['at'] >= UNDO_S:
2630
+ self.undo, self.message = None, 'nothing to undo'
2631
+ return
2632
+ record = self.undo['record']
2633
+ message = 'Restored ' + record['name']
2634
+ try:
2635
+ R.reregister(record)
2636
+ except R.Unpublished as error: # saved, not yet published: undone all the same
2637
+ message = str(error)
2638
+ except (L.LanesError, OSError, ValueError) as error:
2639
+ self.message = str(error)
2640
+ return
2641
+ self.undo = None
2642
+ self.reload(force=True) # the cursor's id is in the registry again: it follows the row back
2643
+ self.message = message
2644
+ self.release_focus()
2645
+
2646
+ def expire_undo(self, now=None):
2647
+ """run()'s tick: the undo offer UNDO_S old goes -> True when it went (one repaint: the footer loses it)."""
2648
+ if self.undo and (time.monotonic() if now is None else now) - self.undo['at'] >= UNDO_S:
2649
+ self.undo = None
2650
+ return True
2651
+ return False
2652
+
2653
+ def undo_text(self, cols):
2654
+ """The undo offer in the footer: ` Unregistered ‘name’ · z undo`, at most `cols − 1` cells — a long name is
2655
+ clipped with the `truncation` mark before the key would be. Every glyph is a mark: seven-bit in ASCII."""
2656
+ g = self.glyphs['marks']
2657
+ head, tail = f' Unregistered {g["quote-open"]}', f'{g["quote-close"]} {g["sep"]} z undo'
2658
+ name = self.undo['record'].get('name', '')
2659
+ room = cols - 1 - cell_width(head) - cell_width(tail)
2660
+ if cell_width(name) > room:
2661
+ name = clip(name, max(0, room - 1))[0] + g['truncation']
2662
+ return head + name + tail
1215
2663
 
1216
2664
  def spools(self):
1217
2665
  """Where an upload request may be spooled, best first. A suffixed view (mac-2, mac-w2) belongs to the machine
@@ -1225,9 +2673,34 @@ class Sidebar:
1225
2673
  return [os.path.join(STATE, 'upload-requests', base), own]
1226
2674
  return [own]
1227
2675
 
2676
+ def pasted(self, text):
2677
+ """A bracketed paste, routed by what is open (cockpit v3 S3 review). Armed, the upload strip is waiting for exactly
2678
+ this: it is the drop. Otherwise, into the open text field — the ` › Rename: ` or ` › Register as: ` name, the
2679
+ filter — it is typed, as its characters would be: a run of line breaks or tabs is one space (the edges' are
2680
+ dropped), so a copied line never acts as Enter; other control characters are dropped; the field keeps the
2681
+ keyboard. With no field open — a confirmation has none — it is a drop (dropped()): a terminal delivers a file
2682
+ dropped on the pane as a paste of its path."""
2683
+ if not self.armed:
2684
+ typed = ''.join(ch for ch in PASTE_BREAKS.sub(' ', text.strip('\t\n\v\f\r')) if ch.isprintable())
2685
+ if self.prompt and self.prompt['stage'] == 'name':
2686
+ self.prompt['value'] += typed
2687
+ return
2688
+ if self.filtering:
2689
+ self.filter += typed
2690
+ if typed:
2691
+ self.set_cursor(0) # the top match, as a typed letter puts it
2692
+ return
2693
+ self.dropped(text)
2694
+
1228
2695
  def dropped(self, text):
1229
- """A drop arrives as a paste of local path(s). Ask this device's upload helper to send them."""
2696
+ """A drop arrives as a paste of local path(s). Ask this device's upload helper to send them, then hand the
2697
+ keyboard back to the lane (cockpit v3 S3): the drop is the action the armed strip held the keyboard for, and
2698
+ the path is typed into the lane when the upload lands. A paste that is not a drop ends it the same way."""
1230
2699
  self.armed = False
2700
+ self.spool_drop(text)
2701
+ self.release_focus()
2702
+
2703
+ def spool_drop(self, text):
1231
2704
  try:
1232
2705
  tokens = shlex.split(text.strip())
1233
2706
  except ValueError:
@@ -1259,7 +2732,9 @@ class Sidebar:
1259
2732
 
1260
2733
  def poll_upload(self):
1261
2734
  """Follow the request in flight. The strip repaints only when this changes something — a new `.progress`
1262
- mtime or the end of the upload (spec §4.6(d)): no spinner, no fast tick."""
2735
+ mtime or the end of the upload (spec §4.6(d)): no spinner, no fast tick. It never moves the focus: dropped()
2736
+ handed the keyboard back when the request was spooled, and by the end of an upload the person may be typing
2737
+ in the lane or anywhere else — a `move-focus right` from there would carry the focus away (cockpit v3 S3)."""
1263
2738
  if not self.upload:
1264
2739
  return
1265
2740
  base = os.path.join(self.upload['spool'], self.upload['id']) # the results land where the request went
@@ -1272,7 +2747,6 @@ class Sidebar:
1272
2747
  note = None
1273
2748
  if os.path.exists(base + '.done'):
1274
2749
  note = ok + ', '.join(self.upload['names'])[:60]
1275
- self.focus_lane()
1276
2750
  elif os.path.exists(base + '.err'):
1277
2751
  try:
1278
2752
  with open(base + '.err') as handle:
@@ -1289,6 +2763,73 @@ class Sidebar:
1289
2763
  pass
1290
2764
  self.upload, self.upload_note, self.upload_note_at, self.dirty = None, note, time.time(), True
1291
2765
 
2766
+ @property
2767
+ def asking(self):
2768
+ """The verb of what the footer is asking — the prompt's (`close` x / ×, `rename` R, `promote` P) or `register`
2769
+ while the Add to workspace picker is open — or None. release_focus() reads it (an open question needs the next
2770
+ key) and a phone's title announces it (osc_bytes). Until cockpit v3 S6 this was the `prompt` property."""
2771
+ if self.prompt:
2772
+ return self.prompt['verb']
2773
+ return 'register' if self.registration else None
2774
+
2775
+ def release_focus(self):
2776
+ """Give the keyboard back to this pane's viewport after an action ends (cockpit v3 S3, plan context item 6). The
2777
+ sidebar and the lane share one keyboard; left on the sidebar, the next sentence a person types becomes `R`ename,
2778
+ `P` or `x` + `y`. Not while a prompt, the filter or the armed upload strip still needs keys, and never on a
2779
+ phone, whose sidebar is a whole tab — going to the lane tab would hide the list the action was on. -> True when
2780
+ the keyboard moved; handle() then drops the rest of the read (it was typed for the lane). Called only for a key
2781
+ or a mouse press (moves_focus): those reach the sidebar only while it has the focus, so the relative
2782
+ `move-focus right` lands on the viewport."""
2783
+ if self.mobile or self.asking or self.filtering or self.armed:
2784
+ return False
2785
+ self.hand_off(self.slot)
2786
+ return True
2787
+
2788
+ def hand_off(self, slot=None):
2789
+ """The one focus move per action (focus_lane), and the mark that the rest of this read is not the sidebar's.
2790
+ The focus legend goes at once (cockpit v3 S4): the keyboard is the lane's before Zellij's `out` report says so.
2791
+ No prompt outlives the keyboard (cockpit v3 S6 fix cycle 1, DEF-1824): one still open here — a path that did
2792
+ not end it first (prompt_sequence ends it before a click opens a lane) — is closed without a word of its own,
2793
+ so the action that moved the keyboard says what it says (` view not running yet`). Left open, its field or
2794
+ question stayed in the footer while the keys went to the lane, and the next key typed on the sidebar answered
2795
+ it. release_focus() never gets here with one open (an open question keeps the keyboard)."""
2796
+ if self.prompt:
2797
+ self.close_prompt()
2798
+ self.focus_lane(slot)
2799
+ self.handed_off = True
2800
+ self.focused, self.legend_until = False, 0.0
2801
+
2802
+ def keys_here(self, now=None):
2803
+ """Whether the footer legend says the keyboard is on this sidebar (cockpit v3 S4). Once Zellij has sent a
2804
+ focus report, `focused` is the truth (reports, keys, hand-offs keep it); before that, a key is the only proof,
2805
+ and the legend stays FOCUS_LEGEND_S after it."""
2806
+ if self.focus_reported:
2807
+ return self.focused
2808
+ return (time.monotonic() if now is None else now) < self.legend_until
2809
+
2810
+ def legend_stale(self, now=None):
2811
+ """run(): the frame on screen says the opposite of keys_here() — the no-report legend ran out — so one repaint.
2812
+ Never on a phone, whose footer has no focus legend (its sidebar is a whole tab)."""
2813
+ return not self.mobile and self.keys_here(now) != self.legend_shown
2814
+
2815
+ @property
2816
+ def message(self):
2817
+ """What the last action said in the footer (` Closed sh`, ` Cancelled`, why a close failed), or ''."""
2818
+ return self._message
2819
+
2820
+ @message.setter
2821
+ def message(self, text):
2822
+ # Every assignment is stamped (cockpit v3 S6): the message lasts MESSAGE_S from when it was SAID.
2823
+ self._message, self.message_at = text, time.monotonic()
2824
+
2825
+ def expire_message(self, now=None):
2826
+ """run()'s tick (cockpit v3 S6): a message MESSAGE_S old goes -> True when it went (one repaint). Otherwise only
2827
+ the next action's begin_action() clears it — never a key, a report, the wheel or a release."""
2828
+ if self._message and (time.monotonic() if now is None else now) - self.message_at >= MESSAGE_S:
2829
+ self._message = ''
2830
+ return True
2831
+ return False
2832
+
1292
2833
  def focus_lane(self, slot=None):
1293
2834
  if os.environ.get('ZELLIJ'):
1294
2835
  actions = ([['go-to-tab-name', self.title]] if self.mobile else
@@ -1320,19 +2861,116 @@ class Sidebar:
1320
2861
  text, _used = clip(text, cols - 1)
1321
2862
  return f'{style}{text}{tone["reset"]}'
1322
2863
 
2864
+ def footer_text(self, cols, entries=(), legend=False):
2865
+ """The footer row -> (text, style, caret): ONE ladder for the desktop and the phone (cockpit v3 S6; until S6 the
2866
+ phone had its own and showed no rename, Register as or register confirmation — DEF-1821). `text` is at most
2867
+ `cols − 1` cells, `cols − 2` when `caret` (a text field's caret takes the last one); `entries` is the list
2868
+ on screen (the filter counts its matches); `legend` that the desktop's keyboard is here (keys_here). Highest
2869
+ first: the prompt (prompt_text) › the Add to workspace picker › the filter › the health banner (a frozen
2870
+ picture must never look live) › the version note (version_note, cockpit v3 V2; not while an update is ready)
2871
+ › a registry error › the last message › the undo offer (undo_text, cockpit v3 S7) › `update ready · reload
2872
+ (r)` (W9 U6: the installed release lays this view out differently; `r` rebuilds it — the desktop's rung and
2873
+ the phone's, in the accent) › the focus legend (desktop) › the idle hint. Only the picker's way out, the
2874
+ 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."""
2876
+ tone, g = self.tone, self.glyphs['marks']
2877
+ sep = f' {g["sep"]} '
2878
+ if self.prompt:
2879
+ return self.prompt_text(cols)
2880
+ if self.registration:
2881
+ way_out = 'Tap Cancel' if self.mobile else 'Esc cancels'
2882
+ text, style = ' ' + (self.message or f'Choose a workspace{sep}{way_out}'), tone['ink-dim']
2883
+ elif self.filtering or self.filter:
2884
+ n = sum(1 for e in entries if not e.get('divider'))
2885
+ text, style = f' /{self.filter}{sep}{n} match{"es" if n != 1 else ""}', tone['ink']
2886
+ elif self.banner:
2887
+ text = ' ' + self.banner
2888
+ style = tone['tone-urgent'] if 'sockets' in self.banner else tone['tone-attention']
2889
+ elif self.version_note(cols): # cockpit v3 V2: this view runs an older (or newer) tree than the installed one
2890
+ text, style = self.version_note(cols), tone['tone-attention']
2891
+ elif self.errors:
2892
+ text, style = f' {g["warning"]} registry: ' + self.errors[0], tone['tone-urgent']
2893
+ elif self.message:
2894
+ text, style = ' ' + self.message, tone['ink-dim']
2895
+ elif self.undo: # cockpit v3 S7: the `u` it undoes, until `z` or UNDO_S
2896
+ text, style = self.undo_text(cols), tone['ink-dim']
2897
+ elif self.update_ready: # W9 U6: the installed release lays this view out differently; `r` rebuilds it
2898
+ text, style = f' update ready{sep}reload (r)', tone['accent']
2899
+ elif self.mobile:
2900
+ text, style = f' Swipe to scroll{sep}Tap to open', tone['ink-dim']
2901
+ elif legend: # 34 cells (35 in ASCII): the plan's ` ▌ keys go to the sidebar · …` is 38
2902
+ text, style = f' {g["cursor"]} keys go to sidebar{sep}Esc {g["arrow"]} lane', tone['accent']
2903
+ elif self.attach_only: # a remote workspace: nothing to close, no upload strip
2904
+ text, style = f' {g["enter"]} open{sep}/ find{sep}? keys', tone['ink-dim']
2905
+ else:
2906
+ text, style = f' {g["enter"]} open{sep}/ find{sep}x close{sep}? keys', tone['ink-dim']
2907
+ return clip(text, max(1, cols - 1))[0], style, False
2908
+
2909
+ def prompt_text(self, cols):
2910
+ """The open prompt's footer -> (text, style, caret), one shape per stage (cockpit v3 S6):
2911
+ a `name` stage is the field ` › Rename: value█` / ` › Register as: value█` (ink; a value longer than the field
2912
+ shows its END after the `truncation` mark, so what is typed and the caret stay in view); a `confirm` stage is
2913
+ ` › <word> ‘name’ — y⏎ confirm · Esc cancel` (close tone-urgent, register tone-attention), `⏎ confirm` once `y`
2914
+ armed it — so typed text whose `y` is followed by a letter closes or registers nothing (S3). The instruction
2915
+ always survives: ` cancel` gives way first, then ` · Esc`, then the name is clipped with the `truncation` mark.
2916
+ Under ASCII glyphs `⏎` is the word `Enter`, so `y` and it are joined by `+`."""
2917
+ prompt, tone, g = self.prompt, self.tone, self.glyphs['marks']
2918
+ if prompt['stage'] == 'name':
2919
+ head = f' {g["lead"]} {PROMPT_FIELDS[prompt["verb"]]}: '
2920
+ room, value = max(1, cols - 2) - cell_width(head), prompt['value']
2921
+ if cell_width(value) > room > 1: # the end of the name, as a one-line editor scrolls
2922
+ tail, used = [], 0
2923
+ for ch in reversed(value):
2924
+ if used + cell_width(ch) > room - 1:
2925
+ break
2926
+ tail.append(ch)
2927
+ used += cell_width(ch)
2928
+ value = g['truncation'] + ''.join(reversed(tail))
2929
+ return clip(head + value, max(1, cols - 2))[0], tone['ink'], True
2930
+ enter = g['enter']
2931
+ keys = enter if prompt['armed'] else f'y{enter}' if cell_width(enter) == 1 else f'y+{enter}'
2932
+ head = f' {g["lead"]} {PROMPT_WORDS[prompt["verb"]]} {g["quote-open"]}'
2933
+ act = f'{g["quote-close"]} {g["dash"]} {keys} confirm'
2934
+ name = prompt['value']
2935
+ style = tone['tone-urgent'] if prompt['verb'] == 'close' else tone['tone-attention']
2936
+ for tail in (f'{act} {g["sep"]} Esc cancel', f'{act} {g["sep"]} Esc', act):
2937
+ room = cols - 1 - cell_width(head) - cell_width(tail)
2938
+ if cell_width(name) <= room:
2939
+ return head + name + tail, style, False
2940
+ shown = clip(name, room - 1)[0] + g['truncation'] if room > 1 else clip(name, max(1, room))[0]
2941
+ return clip(head + shown + tail, max(1, cols - 1))[0], style, False
2942
+
2943
+ def footer_row(self, cols, entries=(), legend=False):
2944
+ """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."""
2946
+ text, style, caret = self.footer_text(cols, entries, legend)
2947
+ reset = self.tone['reset']
2948
+ painted = f'{reset}{self.tone["tone-working"]}{self.glyphs["marks"]["caret"]}{reset}' if caret else ''
2949
+ return f'{style}{text}{painted}{reset}'
2950
+
1323
2951
  def divider_line(self, entry, cols):
1324
2952
  """`── ◆ Needs you (3) ───` (spec §4.4), shared by the desktop and the phone (§4.10 "section headers as
1325
2953
  desktop"): the rule ink-faint, the words ink-dim, the glyph of the section's most urgent member in its tone,
1326
2954
  filled to cols − 1 with the `rule` mark (never a literal: the ASCII frame is seven-bit). Column 36 is never
1327
- written; the caller adds `\\033[K`."""
2955
+ written; the caller adds `\\033[K`. An EMPTY section (`── ▸ Working (0)`, drawn since cockpit v3 S2) has no
2956
+ member to lend a tone: its own glyph is ink-dim, so a placeholder never reads as an alarm. In the vivid modes
2957
+ (cockpit v3 P2) a section WITH members says its words in its tone (SECTION_TONE) and its `(n)` ink-dim:
2958
+ `── ◇ Needs you (3)` reads attention, not grey; an empty one stays the dim placeholder, 16 colours v1's bytes."""
1328
2959
  tone, marks, reset = self.tone, self.glyphs['marks'], self.tone['reset']
1329
2960
  attention = next((s for s in ATTENTION if s[0] == entry.get('section')), None)
1330
2961
  rule, dim, faint = marks['rule'], tone['ink-dim'], tone['ink-faint']
1331
- status = (entry.get('status') if entry.get('status') in AGENT_GLYPHS else attention[2]) if attention else None
2962
+ member = entry.get('status') if entry.get('status') in AGENT_GLYPHS else None
2963
+ status = (member or attention[2]) if attention else None
1332
2964
  glyph = f'{self.glyphs["agent"][status]} ' if status else ''
1333
2965
  label, used_cols = clip(f'{glyph}{entry["name"]}', max(1, cols - 5))
1334
- if status and label.startswith(glyph):
1335
- painted = f'{tone[AGENT_GLYPHS[status][2]]}{glyph[:-1]}{reset}{dim} {label[len(glyph):]} {reset}'
2966
+ if status and label.startswith(glyph) and member and self.vivid:
2967
+ words, paren, count = label[len(glyph):].rpartition(' (')
2968
+ words, count = (words, paren + count) if paren else (count, '') # a clipped label keeps its words
2969
+ painted = (f'{tone[AGENT_GLYPHS[status][2]]}{glyph[:-1]}{reset} {tone[SECTION_TONE[attention[0]]]}{words}'
2970
+ f'{reset}{dim}{count} {reset}')
2971
+ elif status and label.startswith(glyph):
2972
+ role = AGENT_GLYPHS[status][2] if member else 'ink-dim'
2973
+ painted = f'{tone[role]}{glyph[:-1]}{reset}{dim} {label[len(glyph):]} {reset}'
1336
2974
  else:
1337
2975
  painted = f'{dim}{label} {reset}'
1338
2976
  fill = rule * max(0, cols - 1 - 4 - used_cols)
@@ -1349,15 +2987,21 @@ class Sidebar:
1349
2987
  return ' ' + sep.join(f'{text} {MOBILE_COUNT_WORDS[status]}' for status, text in tally)
1350
2988
 
1351
2989
  def key_sheet(self, cols, body):
1352
- """`body` lines: the key sheet `?` shows (spec §4.7), ` {key:<10}{action}`, the key ink-dim; padded with
1353
- blank lines, cut to the body. The seven-bit twins under LANES_GLYPHS=ascii."""
2990
+ """`body` lines: the key sheet `?` shows (spec §4.7) — first ` lanes <current> · view <tree>` (cockpit v3 V2),
2991
+ then ` {key:<9} {action}`, the key ink-dim; padded with blank lines, cut to the body. The seven-bit twins under LANES_GLYPHS=ascii. The action is clipped to what the
2992
+ row has left after its key field (cols − 13 after a key of at most 9 cells, one less after `Ctrl-Alt-l`)."""
1354
2993
  index = 1 if self.glyph_mode == 'ascii' else 0
1355
2994
  dim, reset = self.tone['ink-dim'], self.tone['reset']
1356
- lines = []
2995
+ # The first row says which lanes (cockpit v3 V2): the installed tree's version, then this view's — in the
2996
+ # attention tone while they differ (the footer's version note says so too).
2997
+ sep = self.glyphs['marks']['sep']
2998
+ version, _ = clip(f' lanes {self.current_version or "?"} {sep} view {self.tree_version or "?"}', max(0, cols - 1))
2999
+ style = self.tone['tone-attention'] if self.current_version != self.tree_version else dim
3000
+ lines = [f'{style}{version}{reset}']
1357
3001
  for row in KEY_SHEET:
1358
- key = row[index]
1359
- action, _ = clip(row[2], max(0, cols - 13))
1360
- lines.append(f'{dim} {key:<10}{reset}{action}')
3002
+ key = f'{row[index]:<9} '
3003
+ action, _ = clip(row[2], max(0, cols - 3 - cell_width(key)))
3004
+ lines.append(f'{dim} {key}{reset}{action}')
1361
3005
  return (lines + [''] * body)[:body]
1362
3006
 
1363
3007
  @staticmethod
@@ -1366,6 +3010,56 @@ class Sidebar:
1366
3010
  return 1.0
1367
3011
 
1368
3012
  # ------------------------------------------------------------ rendering
3013
+ def place_body(self, entries, body):
3014
+ """The desktop body (render): -> ([entry index of each row but `⋮ N more`'s], how many entries that row hides —
3015
+ 0 when the rest of the list fits). Sets `self.top`.
3016
+ `top` is pinned (resolve_cursor never moves it, cockpit v3 S2) and changes here only to keep the cursor's lane in
3017
+ the body — the minimal scroll — or because the list got shorter: a list that ends inside the body is never
3018
+ scrolled past its first rows (nothing above would say they are hidden; `⋮` only marks rows below). While the
3019
+ list continues past the body its last row says `⋮ N more` (spec §4.7), so it never carries the cursor.
3020
+ A lane the owner pinned (cockpit v3 R5) never falls below `⋮ N more`: the rows above it are RESERVED for every
3021
+ pinned lane the window would hide below — the window from `top` gets that many rows fewer, the pinned lanes
3022
+ follow it in list order, then `⋮ N more` (at most body − 2 of them; the window keeps a row). A cursor on such a
3023
+ row needs no scroll. Without pins this is the scroll render() always did, row for row."""
3024
+ count = len(entries)
3025
+ self.top = max(0, min(self.top, count - body))
3026
+ if self.cursor < self.top:
3027
+ self.top = self.cursor
3028
+ pinned = ([i for i, e in enumerate(entries) if e.get('id') in self.lane_pins and not e.get('divider')]
3029
+ if self.lane_pins else [])
3030
+ for _ in range(count + 2): # each pass moves `top` down towards the cursor: it ends
3031
+ if body <= 1 or self.top + body >= count:
3032
+ if self.cursor >= self.top + body:
3033
+ self.top = min(self.cursor - body + 1, max(0, count - body))
3034
+ continue
3035
+ return list(range(self.top, self.top + body)), 0
3036
+ reserved = []
3037
+ while True: # fewer window rows can only reserve more pins: this settles
3038
+ below = [i for i in pinned if i >= self.top + body - 1 - len(reserved)][:max(0, body - 2)]
3039
+ if len(below) == len(reserved):
3040
+ break
3041
+ reserved = below
3042
+ window = body - 1 - len(reserved)
3043
+ if self.top <= self.cursor < self.top + window or self.cursor in reserved:
3044
+ rows = list(range(self.top, self.top + window)) + reserved
3045
+ return rows, count - self.top - window - len(reserved)
3046
+ self.top = min(self.cursor - window + 1, count - body)
3047
+ return list(range(self.top, self.top + body)), 0
3048
+
3049
+ @staticmethod
3050
+ def sections_at(entries, rows):
3051
+ """{entry index: the attention section it sits in} for the body's `rows` — the last divider at or above it, as
3052
+ a reserved pinned row (place_body) is not below the row before it. One pass up to the last row."""
3053
+ wanted, found, section = set(rows), {}, None
3054
+ for index in range(max(rows) + 1 if rows else 0):
3055
+ if index >= len(entries):
3056
+ break
3057
+ if entries[index].get('divider'):
3058
+ section = entries[index].get('section')
3059
+ if index in wanted:
3060
+ found[index] = section
3061
+ return found
3062
+
1369
3063
  def render(self):
1370
3064
  try:
1371
3065
  cols, rows = os.get_terminal_size(sys.stdout.fileno())
@@ -1376,8 +3070,7 @@ class Sidebar:
1376
3070
  self.render_mobile(cols, rows)
1377
3071
  return
1378
3072
  entries = self.follow() # the cursor re-resolved onto its lane, clamped (spec §4.5)
1379
- if self.theme_pending and time.monotonic() >= self.theme_deadline:
1380
- self.apply_theme(self.variant) # no OSC 11 answer within the grace: dark it is (spec §2.4)
3073
+ self.expire_theme() # no OSC 11 answer within the grace: dark it is (spec §2.4)
1381
3074
  now = time.time()
1382
3075
  # Frame order (spec §4.6): header, body, upload strip (rows ≥ 14), strip row (rows ≥ 18, economy or fleet
1383
3076
  # attention to show), rule, footer. `box_top` is the upload strip's 1-based row; a click at or below it arms.
@@ -1387,18 +3080,9 @@ class Sidebar:
1387
3080
  self.strip_row = rows >= 18 and (bool(economy_line) or bool(self.fleet))
1388
3081
  body = max(1, rows - 3 - (BOX_ROWS if self.box else 0) - (1 if self.strip_row else 0))
1389
3082
  self.box_top = 2 + body
1390
- # resolve_cursor()'s eye-preserving shift is bounded by the list: a list that ends inside the body is never
1391
- # scrolled past its first rows (nothing above would say they are hidden; `⋮` only marks rows below).
1392
- self.top = max(0, min(self.top, len(entries) - body))
1393
- if self.cursor < self.top:
1394
- self.top = self.cursor
1395
- if self.cursor >= self.top + body:
1396
- self.top = self.cursor - body + 1
1397
- # The last body row says `⋮ N more` while the list continues (spec §4.7), so it never carries the cursor:
1398
- # a cursor that would sit under it scrolls the list one row further.
1399
- if body > 1 and self.top + body < len(entries) and self.cursor >= self.top + body - 1:
1400
- self.top = self.cursor - body + 2
1401
- self.more_row = 1 + body if body > 1 and self.top + body < len(entries) else 0 # 1-based screen row, 0 = none
3083
+ rows, hidden_below = self.place_body(entries, body)
3084
+ self.body_map = rows + ([next(i for i in range(self.top, len(entries)) if i not in rows)] if hidden_below else [])
3085
+ self.more_row = 1 + body if hidden_below else 0 # 1-based screen row of `⋮ N more`, 0 = none
1402
3086
  target = self.current_target()
1403
3087
  others = {slot: self.slot_target(slot) for slot in SLOTS[1:self.viewports]}
1404
3088
  out = ['\033[?25l\033[H']
@@ -1408,7 +3092,7 @@ class Sidebar:
1408
3092
  # its tone — the same glyphs as column 2, so they are the legend. No agents: `{n} lanes`, ink-dim. While the
1409
3093
  # health banner is up the counts are ink-dim too: a frozen status.json is not live.
1410
3094
  tally = self.header_tally()
1411
- right = ' '.join(text for _status, text in tally) or f'{len(self.entries)} lanes'
3095
+ right = ' '.join(text for _status, text in tally) or f'{len(self.shown_lanes())} lanes'
1412
3096
  right_width = cell_width(right)
1413
3097
  header, used = clip(' Add to workspace' if self.registration else f' {self.title}', max(1, cols - right_width - 2))
1414
3098
  if tally and not self.banner:
@@ -1418,86 +3102,69 @@ class Sidebar:
1418
3102
  # Column 36 is never written, not even with a blank (the budget gate's cell walk, DEF-1417): `\033[K` clears it.
1419
3103
  out.append(f'{tone["bold"]}{header}{reset}{" " * max(1, cols - 1 - used - right_width)}{painted}\033[K\r\n')
1420
3104
  more = bool(self.more_row) and not self.showing_keys
1421
- section = None
1422
- for index in range(self.top): # the section the first visible row sits in (dividers above `top`)
1423
- if entries[index].get('divider'):
1424
- section = entries[index].get('section')
1425
3105
  g = marks
1426
3106
  sep, q0, q1 = f' {g["sep"]} ', g['quote-open'], g['quote-close']
1427
3107
  if self.showing_keys: # spec §4.7: `?` replaces the body with the key sheet until any key
1428
3108
  out.extend(f'{line}\033[K\r\n' for line in self.key_sheet(cols, body))
3109
+ sections = self.sections_at(entries, rows) if rows else {}
1429
3110
  for row in range(0 if self.showing_keys else body):
1430
- index = self.top + row
3111
+ index = rows[row] if row < len(rows) else len(entries)
1431
3112
  if row == 0 and self.filter and not entries: # spec §4.7: filter with no hits
1432
3113
  label, _ = clip(f' no lane matches {q0}{self.filter}{q1}{sep}Esc', cols - 1)
1433
3114
  out.append(f'{tone["ink-dim"]}{label}{reset}\033[K\r\n')
1434
3115
  continue
1435
- if index >= len(entries):
1436
- out.append('\033[K\r\n')
1437
- continue
1438
3116
  if more and row == body - 1: # spec §4.7: the list continues — ` ⋮ N more`, the hidden rows counted
1439
- label, _ = clip(f' {marks["more"]} {len(entries) - index} more', cols - 1)
3117
+ label, _ = clip(f' {marks["more"]} {hidden_below} more', cols - 1)
1440
3118
  out.append(f'{tone["ink-dim"]}{label}{reset}\033[K\r\n')
1441
3119
  continue
3120
+ if index >= len(entries):
3121
+ out.append('\033[K\r\n')
3122
+ continue
1442
3123
  entry = entries[index]
3124
+ section = sections.get(index)
1443
3125
  if entry.get('divider'):
1444
- section = entry.get('section')
1445
3126
  out.append(self.divider_line(entry, cols) + '\033[K\r\n')
1446
3127
  continue
1447
3128
  is_cursor = index == self.cursor
3129
+ band = self.on_band if is_cursor else str # the cursor row lies on the `selected` band (P2)
1448
3130
  cursor = f'{tone["accent"]}{marks["cursor"]}{reset}' if is_cursor else ' '
3131
+ # A shell on its way (cockpit v3 S9): the `pending` mark, dim, in the New shell row's blank column — the
3132
+ # empty workspace's column 2, else column 3 (a lane row's mark column). Same cells, same width.
3133
+ waiting = f'{tone[MARKS["pending"][2]]}{marks["pending"]}{reset}' if entry.get('pending') else ''
1449
3134
  if entry.get('action') == 'new-shell' and not self.entries and not self.filter:
1450
3135
  # spec §4.7: an empty workspace says so on the New shell row itself (still the row that creates one)
1451
3136
  label, used_cols = clip(f' no lanes declared{sep}{g["new"]} New shell', max(1, cols - 2))
1452
- out.append(f'{cursor}{tone["ink-dim"]}{label}{reset}{" " * max(0, cols - 2 - used_cols)}\033[K\r\n')
3137
+ if waiting:
3138
+ label = label[1:]
3139
+ out.append(band(f'{cursor}{waiting}{tone["ink-dim"]}{label}{reset}{" " * max(0, cols - 2 - used_cols)}')
3140
+ + '\033[K\r\n')
1453
3141
  continue
1454
3142
  if entry.get('action') == 'new-shell':
1455
3143
  # ` + New shell` (spec §4.8): the `new` mark in column 2 (tone-done), the label ink-dim, padded to 35.
1456
3144
  used_cols = 4 + cell_width('New shell')
1457
- out.append(f'{cursor}{tone["tone-done"]}{marks["new"]}{reset} {tone["ink-dim"]}New shell{reset}'
1458
- f'{" " * max(0, cols - 1 - used_cols)}\033[K\r\n')
3145
+ out.append(band(f'{cursor}{tone["tone-done"]}{marks["new"]}{reset}{waiting or " "} '
3146
+ f'{tone["ink-dim"]}New shell{reset}{" " * max(0, cols - 1 - used_cols)}') + '\033[K\r\n')
1459
3147
  continue
1460
3148
  if entry.get('action'): # the Add-to-workspace picker's choices and its Cancel
1461
3149
  label, used_cols = clip(entry['name'], max(1, cols - 4))
1462
- out.append(f'{cursor} {tone["tone-done"]}{label}{reset}{" " * max(0, cols - 4 - used_cols)}\033[K\r\n')
3150
+ out.append(band(f'{cursor} {tone["tone-done"]}{label}{reset}{" " * max(0, cols - 4 - used_cols)}')
3151
+ + '\033[K\r\n')
1463
3152
  continue
1464
- out.append(self.row(entry, index, target, others, cols, now,
1465
- 'tone-attention' if section == 'needs' else None) + '\033[K\r\n')
1466
- # The footer ladder (spec §4.6(a)): confirm › rename › promote › (picker) › filter › banner › registry error ›
1467
- # message › legend. Every prompt has one shape — the `lead` mark, a sentence, `y/n` or the caret — and every
1468
- # separator, quote, `⏎`, `›` and `⚠` is a mark lookup, so the ASCII footer is seven-bit.
1469
- lead, caret = g['lead'], ''
1470
- if self.confirming:
1471
- footer, footer_style = f" {lead} close {q0}{self.confirming.get('name', '')}{q1}? y/n", tone['tone-urgent']
1472
- elif self.renaming:
1473
- footer, footer_style = f' {lead} Rename: {self.rename_value}', tone['ink']
1474
- caret = f'{tone["tone-working"]}{g["caret"]}{reset}'
1475
- elif self.promoting:
1476
- name = (self.promote_entry or {}).get('name', '')
1477
- footer, footer_style = f" {lead} Register {q0}{name}{q1}? y/n", tone['tone-attention']
1478
- elif self.registration:
1479
- footer, footer_style = ' ' + (self.message or f'Choose a workspace{sep}Esc cancels'), tone['ink-dim']
1480
- elif self.filtering or self.filter:
1481
- n = sum(1 for e in entries if not e.get('divider'))
1482
- footer, footer_style = f' /{self.filter}{sep}{n} match{"es" if n != 1 else ""}', tone['ink']
1483
- elif self.banner: # above everything but a prompt and the filter: a frozen picture must never look live
1484
- footer = ' ' + self.banner
1485
- footer_style = tone['tone-urgent'] if 'sockets' in self.banner else tone['tone-attention']
1486
- elif self.errors:
1487
- footer, footer_style = f' {g["warning"]} registry: ' + self.errors[0], tone['tone-urgent']
1488
- elif self.message:
1489
- footer, footer_style = ' ' + self.message, tone['ink-dim']
1490
- elif self.attach_only: # a remote workspace: nothing to close, no upload strip
1491
- footer, footer_style = f' {g["enter"]} open{sep}/ find{sep}? keys', tone['ink-dim']
1492
- else:
1493
- footer, footer_style = f' {g["enter"]} open{sep}/ find{sep}x close{sep}? keys', tone['ink-dim']
1494
- footer, _ = clip(footer, cols - (2 if caret else 1)) # the caret takes the last of the 35 cells
1495
- footer += f'{reset}{caret}' if caret else ''
3153
+ # The age's tone (spec §4.2 col 29–31): attention inside Needs you; in the vivid modes each attention
3154
+ # section's own tone, ink-dim only outside them (cockpit v3 P2). 16 colours keep the one exception.
3155
+ age_tone = SECTION_TONE.get(section) if self.vivid else 'tone-attention' if section == 'needs' else None
3156
+ out.append(self.row(entry, index, target, others, cols, now, age_tone) + '\033[K\r\n')
3157
+ # The footer (spec §4.6(a)): ONE ladder, footer_text(), shared with the phone since cockpit v3 S6 — the prompt ›
3158
+ # the picker › the filter › banner › registry error › message › legend. While the keyboard is on the sidebar the
3159
+ # legend rung itself says so, in the accent (keys_here, cockpit v3 S4): ` ▌ keys go to sidebar · Esc → lane` —
3160
+ # no extra row, every rung above it unchanged.
3161
+ self.legend_shown = self.keys_here()
1496
3162
  if self.box:
1497
3163
  out.append(self.strip_line(cols) + '\033[K\r\n')
1498
3164
  if self.strip_row: # spec §4.6(c): the fleet economy line first; without one, fleet attention
1499
3165
  out.append((economy_line or self.fleet_attention(cols - 2)) + '\033[K\r\n')
1500
- out.append(f'{tone["ink-faint"]}{g["rule"] * cols}{reset}\r\n{footer_style}{footer}{reset}\033[K\033[J')
3166
+ out.append(f'{tone["ink-faint"]}{g["rule"] * cols}{reset}\r\n'
3167
+ f'{self.footer_row(cols, entries, self.legend_shown)}\033[K\033[J')
1501
3168
  out.append(self.osc_bytes(target)) # title / notification strings: after the footer, same write (§4.9)
1502
3169
  sys.stdout.write(''.join(out))
1503
3170
  sys.stdout.flush()
@@ -1507,6 +3174,7 @@ class Sidebar:
1507
3174
  """Three terminal rows per lane make the entire row a finger-sized touch target (spec §4.10): the name with
1508
3175
  its agent glyph, then `label · age · harness`, then the agent's task."""
1509
3176
  entries = self.follow()
3177
+ self.body_map = None # the phone's rows are `top + row` (three terminal rows each); place_body is the desktop's
1510
3178
  self.box = False # the Mac file-drop helper is not available in a phone browser
1511
3179
  self.entry_top, self.entry_height = 3, 3 if rows >= 8 else 1
1512
3180
  body = max(1, (rows - 3) // self.entry_height)
@@ -1515,17 +3183,14 @@ class Sidebar:
1515
3183
  if self.cursor >= self.top + body:
1516
3184
  self.top = self.cursor - body + 1
1517
3185
  target = self.current_target()
1518
- if self.theme_pending and time.monotonic() >= self.theme_deadline:
1519
- self.apply_theme(self.variant)
3186
+ self.expire_theme()
1520
3187
  out = ['\033[?25l\033[H']
1521
3188
  tone, marks = self.tone, self.glyphs['marks']
1522
3189
  reset = tone['reset']
1523
3190
 
1524
- def line(value='', style='', closable=False, can_register=False):
1525
- value, _ = clip(value, max(1, cols - (9 if can_register else 5 if closable else 1)))
1526
- close = f'\033[{max(1, cols - 2)}G{tone["tone-urgent"]}{marks["close"]}{reset}' if closable else ''
1527
- add = f'\033[{max(1, cols - 6)}G{tone["tone-done"]}{marks["new"]}{reset}' if can_register else ''
1528
- out.append(f'{reset}{style}{value}{reset}{add}{close}\033[K\r\n')
3191
+ def line(value='', style=''):
3192
+ value, _ = clip(value, max(1, cols - 1))
3193
+ out.append(f'{reset}{style}{value}{reset}\033[K\r\n')
1529
3194
 
1530
3195
  # Header (spec §4.10): the agent counts in words, bold ink — no cyan, no `LANES` banner: the phone shell's own
1531
3196
  # header carries identity. No agents: ` {n} lanes`, ink-dim, as on the desktop (§4.3).
@@ -1533,7 +3198,7 @@ class Sidebar:
1533
3198
  if self.registration:
1534
3199
  line(' ADD TO WORKSPACE', tone['bold'])
1535
3200
  else:
1536
- line(counts or f' {len(self.entries)} lanes', tone['ink-dim'] if self.banner or not counts else tone['bold'])
3201
+ line(counts or f' {len(self.shown_lanes())} lanes', tone['ink-dim'] if self.banner or not counts else tone['bold'])
1537
3202
  line()
1538
3203
  now, sep = time.time(), f' {marks["sep"]} ' # every ` · ` is the sep mark: the ASCII frame stays seven-bit
1539
3204
  for index in range(self.top, self.top + body):
@@ -1549,6 +3214,10 @@ class Sidebar:
1549
3214
  stale = bool(observed.get('stale'))
1550
3215
  if entry.get('divider'): # section headers as desktop (§4.4), three rows tall: the touch grid
1551
3216
  out.append(self.divider_line(entry, cols) + '\033[K\r\n')
3217
+ elif entry.get('action') == 'new-shell' and entry.get('pending'):
3218
+ # a shell on its way (cockpit v3 S9): the dim `pending` mark in the blank cell after the `+`
3219
+ waiting = f'{reset}{tone[MARKS["pending"][2]]}{marks["pending"]}{reset}{tone["tone-done"]}'
3220
+ out.append(f'{reset}{tone["tone-done"]} {marks["new"]}{waiting} New shell{reset}\033[K\r\n')
1552
3221
  elif entry.get('action') == 'new-shell':
1553
3222
  line(f' {marks["new"]} New shell', tone['tone-done'])
1554
3223
  elif entry.get('action'): # the Add-to-workspace picker's choices and its Cancel
@@ -1557,10 +3226,18 @@ class Sidebar:
1557
3226
  # Row A: column 2 the viewing mark (accent), column 3 the agent glyph in its tone (ink-dim while the
1558
3227
  # status is stale), the name bold in its identity colour. The `›` touch cursor is gone (the cursor
1559
3228
  # still moves with J/K). The viewer dots are the sidebar's rule, not the desktop render's: the phone
1560
- # is the one surface where the operator cannot also see the Studio tab (DEF-983).
1561
- closable = bool(entry.get('closable'))
3229
+ # is the one surface where the operator cannot also see the Studio tab (DEF-983). `+` and `×` sit at
3230
+ # the desktop row's columns, `cols − 8` and `cols − 1` (cockpit v3 S5; `cols − 6` / `cols − 2` until
3231
+ # then), inside the hit boxes handle() shares with it; the name still ends left of the `+`. A row `P`
3232
+ # is registering (cockpit v3 S8) has the dim `pending` mark in column 2 and neither control.
3233
+ pending = bool(entry.get('pending'))
3234
+ closable = bool(entry.get('closable')) and not pending
1562
3235
  can_register = closable and R.eligible(entry)
1563
3236
  mark = f'{tone["accent"]}{marks["viewing"]}{reset}' if entry['id'] == target else ' '
3237
+ if mark == ' ' and entry['id'] in self.lane_pins: # the owner's pin, below viewing (R5)
3238
+ mark = f'{tone[MARKS["pinned"][2]]}{marks["pinned"]}{reset}'
3239
+ if pending:
3240
+ mark = f'{tone[MARKS["pending"][2]]}{marks["pending"]}{reset}'
1564
3241
  glyph = ' '
1565
3242
  if status:
1566
3243
  role = 'ink-dim' if stale else tokens.tone_of(AGENT_GLYPHS, status)
@@ -1570,8 +3247,10 @@ class Sidebar:
1570
3247
  name, _ = clip(entry.get('name', entry['id']), width)
1571
3248
  color = entry.get('color')
1572
3249
  fg = f'\033[38;5;{color}m' if isinstance(color, int) and self.color != 'none' else ''
1573
- close = f'\033[{max(1, cols - 2)}G{tone["tone-urgent"]}{marks["close"]}{reset}' if closable else ''
1574
- add = f'\033[{max(1, cols - 6)}G{tone["tone-done"]}{marks["new"]}{reset}' if can_register else ''
3250
+ if entry['id'] in self.lane_hidden: # shown by `o` (R5): faint
3251
+ fg = tone['ink-faint']
3252
+ close = f'\033[{max(1, cols - 1)}G{tone["tone-urgent"]}{marks["close"]}{reset}' if closable else ''
3253
+ add = f'\033[{max(1, cols - 8)}G{tone["tone-done"]}{marks["new"]}{reset}' if can_register else ''
1575
3254
  dots = f'{tone["ink-dim"]}{viewers}{reset}' if viewers else ''
1576
3255
  out.append(f'{reset} {mark}{glyph} {tone["bold"]}{fg}{name}{reset}{dots}{add}{close}\033[K\r\n')
1577
3256
  if self.entry_height == 1:
@@ -1602,15 +3281,9 @@ class Sidebar:
1602
3281
  line(' ' + task if task else '', tone['ink-dim'])
1603
3282
  for _ in range(max(0, rows - (2 + body * self.entry_height + 1))):
1604
3283
  line()
1605
- q0, q1 = marks['quote-open'], marks['quote-close']
1606
- footer = (f" {marks['lead']} close {q0}{self.confirming.get('name', '')}{q1}? y/n" if self.confirming else
1607
- ' ' + (self.message or f'Choose a workspace{sep}Tap Cancel') if self.registration else
1608
- ' /' + self.filter if self.filtering or self.filter else
1609
- ' ' + self.banner if self.banner else
1610
- ' ' + self.errors[0] if self.errors else
1611
- ' ' + self.message if self.message else f' Swipe to scroll{sep}Tap to open')
1612
- footer, _ = clip(footer, max(1, cols - 1))
1613
- out.append(f'{tone["tone-urgent"] if self.confirming else tone["ink-dim"]}{footer}{reset}\033[K\033[J')
3284
+ # The footer the desktop draws (footer_text, cockpit v3 S6): every prompt, the filter, the banner, the message;
3285
+ # only the picker's way out (`Tap Cancel`) and the idle hint (`Swipe to scroll · Tap to open`) are the phone's.
3286
+ out.append(self.footer_row(cols, entries) + '\033[K\033[J')
1614
3287
  out.append(self.osc_bytes(target)) # the phone reads the counts from this pane's title; no OSC 9 here
1615
3288
  sys.stdout.write(''.join(out))
1616
3289
  sys.stdout.flush()
@@ -1618,63 +3291,107 @@ class Sidebar:
1618
3291
 
1619
3292
  # ---------------------------------------------------------------- input
1620
3293
  def handle(self, data):
3294
+ """One raw read: the reports out first (take_reports), then the input (handle_input)."""
3295
+ self.handle_input(self.take_reports(data))
3296
+
3297
+ def take_reports(self, data):
3298
+ """What in a raw read is no input -> the rest (keys, mouse reports, a paste). An OSC 11 answer (`\\033]11;rgb:…`
3299
+ ST/BEL, possibly split across reads) is a theme, never an Esc: it is taken out before anything reads the keys,
3300
+ so it cannot cancel a filter or a prompt (spec §2.4, Review Focus 6). A focus report is noted and taken out
3301
+ (note_focus, cockpit v3 S4)."""
1621
3302
  if isinstance(data, bytes): # the raw read, as run() gets it from the pty
1622
3303
  data = data.decode('utf-8', 'replace')
1623
- # An OSC 11 answer (`\033]11;rgb:…` ST/BEL, possibly split across reads) is a theme, never an Esc: it is taken
1624
- # out before anything reads the keys, so it cannot cancel a filter or a prompt (spec §2.4, Review Focus 6).
1625
- data = self.strip_osc(data)
1626
- # Bracketed paste (a drop, or a real paste) can span several reads: buffer until its end marker.
1627
- if self.paste_buffer is not None:
1628
- self.paste_buffer += data
1629
- data = ''
1630
- elif PASTE_START in data:
1631
- before, _, after = data.partition(PASTE_START)
1632
- self.paste_buffer, data = after, before
1633
- if self.paste_buffer is not None and PASTE_END in self.paste_buffer:
1634
- pasted, _, tail = self.paste_buffer.partition(PASTE_END)
1635
- self.paste_buffer = None
1636
- self.dropped(pasted)
3304
+ return self.note_focus(self.strip_osc(data))
3305
+
3306
+ def handle_input(self, data):
3307
+ """The input of one read, its reports already taken out (take_reports)."""
3308
+ self.handed_off = False # cockpit v3 S3: set when an action of THIS read gives the keyboard back
3309
+ # Bracketed paste (a drop, or a real paste) can span several reads: buffered until its end marker. It is taken at
3310
+ # its place in the read (S3 review): the keys before it act first — `P`, Backspace, then the paste fills the
3311
+ # emptied name — and pasted() routes it by what is open then. Once an action hands the keyboard back, the rest
3312
+ # of the read, a paste included, was typed for the lane.
3313
+ while data and not self.handed_off:
3314
+ if self.paste_buffer is not None:
3315
+ self.paste_buffer += data
3316
+ if PASTE_END not in self.paste_buffer:
3317
+ return
3318
+ pasted, _, data = self.paste_buffer.partition(PASTE_END)
3319
+ self.paste_buffer = None
3320
+ self.pasted(pasted)
3321
+ self.dirty = True
3322
+ continue
3323
+ keys, start, data = data.partition(PASTE_START)
3324
+ self.handle_keys(keys)
3325
+ if not start or self.handed_off:
3326
+ return
3327
+ self.paste_buffer = ''
3328
+
3329
+ def note_focus(self, data):
3330
+ """Take the focus reports out of a read (cockpit v3 S4) and note where the keyboard is -> the read without them.
3331
+ A report is never a key: left in, `ESC [ I` would cancel a confirmation, close the key sheet or hand the
3332
+ keyboard back. What came LAST decides: the last report, unless a key that proves the focus (moves_focus: a
3333
+ typed key, a paste, a mouse press — never the wheel or a release) follows it. Without any report, such a key
3334
+ shows the legend for FOCUS_LEGEND_S. Taken from the whole read, as strip_osc() does: nothing the sidebar takes
3335
+ from a paste (a path, a name, filter text) carries a raw ESC."""
3336
+ before, after = self.focused, data
3337
+ last = None
3338
+ for last in FOCUS_REPORT.finditer(data):
3339
+ pass
3340
+ if last:
3341
+ self.focus_reported, self.focused = True, last.group(1) == 'I'
3342
+ after = data[last.end():]
3343
+ data = FOCUS_REPORT.sub('', data)
3344
+ proof = proves_focus(after)
3345
+ if proof:
3346
+ self.focused, self.legend_until = True, time.monotonic() + FOCUS_LEGEND_S
3347
+ if proof or self.focused != before:
1637
3348
  self.dirty = True
1638
- data += tail
3349
+ return data
3350
+
3351
+ def handle_keys(self, data):
3352
+ """The keys and mouse reports of one read, or of the part of it before / after a paste (handle())."""
3353
+ if not data:
3354
+ return
1639
3355
  entries = self.follow()
1640
3356
  i = 0
1641
3357
  while i < len(data):
3358
+ if self.handed_off:
3359
+ # cockpit v3 S3: an action ended and gave the keyboard back to the lane. What else this read carries was
3360
+ # typed before that focus move landed — for the lane, not for us: consumed, never acted on (`x y ⏎`
3361
+ # followed by a sentence renames, filters and closes nothing). Dropped, not forwarded: the sidebar
3362
+ # never types into a lane (an agent's input) on its own.
3363
+ break
1642
3364
  if self.showing_keys:
1643
3365
  # spec §4.7: any key closes the key sheet and is consumed, never acted on. Checked per key, not per
1644
3366
  # read: one read can carry several keys (key repeat, fast typing), so `?j` opens and closes the sheet
1645
3367
  # with the j consumed, and `?xy` consumes the x — exactly one key or one whole escape sequence each.
3368
+ # Closing it ends an action: a key or a click hands the keyboard back; a wheel tick or a release
3369
+ # closes it in place (moves_focus).
3370
+ n = key_length(data, i)
1646
3371
  self.showing_keys, self.dirty = False, True
1647
- i += key_length(data, i)
3372
+ if moves_focus(data[i:i + n]):
3373
+ self.release_focus()
3374
+ i += n
1648
3375
  continue
1649
3376
  entries = self.follow() # per key: an action may have changed the list (spec §4.5)
1650
3377
  ch = data[i]
1651
- if self.confirming is not None:
1652
- # `y` closes the entry that asked; anything else cancels. A key is consumed by the cancel; an escape
1653
- # sequence (Esc, an arrow, a click) cancels and is then handled as usual. Only a key or a mouse PRESS
1654
- # answers: under ?1000h/?1006h the terminal reports the RELEASE that follows every press (the very
1655
- # release of the `×` click that opened this prompt), and wheel/motion reports are no answer either,
1656
- # so those are consumed and the prompt stays up.
1657
- if ch == '\033' and data[i + 1:].startswith('[<'):
1658
- rest = data[i + 1:]
1659
- end = next((k for k, c in enumerate(rest) if c in 'Mm'), -1)
1660
- if end < 0:
1661
- break # an incomplete report is no answer
1662
- try:
1663
- button = int(rest[2:end].split(';')[0])
1664
- except ValueError:
1665
- button = -1
1666
- if rest[end] == 'm' or (button >= 0 and button & (32 | 64)):
1667
- i += end + 2
1668
- continue
1669
- entry, self.confirming, self.dirty = self.confirming, None, True
1670
- if ch in 'yY':
1671
- self.close_lane(entry)
1672
- i += 1
1673
- continue
1674
- self.message = 'Cancelled'
1675
- if ch != '\033':
1676
- i += 1
3378
+ if self.prompt and ch == '\033':
3379
+ # ONE escape sequence under the prompt, a name field or a confirmation (cockpit v3 S6 fix cycle 1,
3380
+ # DEF-1824): consumed while the prompt stays, or handled as usual below — with the prompt cancelled
3381
+ # first, save a click on the upload strip beside a name field (prompt_sequence).
3382
+ n = key_length(data, i)
3383
+ if not self.prompt_sequence(data[i:i + n]):
3384
+ i += n
1677
3385
  continue
3386
+ elif self.prompt and self.prompt['stage'] == 'confirm':
3387
+ # Two keys (cockpit v3 S3), for a close and a register alike (one prompt, cockpit v3 S6): `y` ARMS it
3388
+ # and the footer names Enter, Enter then acts on the entry that asked; anything else — at either stage,
3389
+ # a second `y` included — cancels (answer_prompt). Typed text closes nothing unless it holds `x`, `y`,
3390
+ # Enter in a row: the letter after a typed `y` cancels. A key is consumed by the cancel, which hands the
3391
+ # keyboard back.
3392
+ self.answer_prompt(ch)
3393
+ i += 1
3394
+ continue
1678
3395
  if ch == '\033':
1679
3396
  rest = data[i + 1:]
1680
3397
  if rest.startswith('[<'): # SGR mouse
@@ -1686,25 +3403,38 @@ class Sidebar:
1686
3403
  except ValueError:
1687
3404
  button, y = -1, 0
1688
3405
  pressed = rest[end] == 'M'
3406
+ # The wheel moves the cursor and never the focus (moves_focus); a release answers nothing.
1689
3407
  if button == 64:
1690
3408
  self.cursor -= 3
1691
3409
  elif button == 65:
1692
3410
  self.cursor += 3
1693
3411
  elif button == 0 and pressed and self.box and y >= self.box_top:
3412
+ self.begin_action()
1694
3413
  self.armed = True # the click also gave this pane the focus: the next drop lands here
1695
3414
  elif button == 0 and pressed:
1696
- index = self.top + (y - self.entry_top) // self.entry_height
3415
+ # A click that opens nothing — a section header, `⋮ N more`, the header row, an empty row —
3416
+ # ends there: the keyboard goes back to the lane (cockpit v3 S3). `×` and `+` open a prompt,
3417
+ # which keeps it; a lane row opens the lane, which hands it off itself.
3418
+ row = (y - self.entry_top) // self.entry_height
3419
+ index = self.top + row
3420
+ if self.body_map is not None and 0 <= row < len(self.body_map):
3421
+ index = self.body_map[row] # the desktop body as render() laid it out (R5)
1697
3422
  if 0 <= index < len(entries) and self.entry_top <= y < self.box_top:
1698
3423
  self.cursor = index
1699
3424
  if y == self.more_row and not self.mobile:
1700
- pass # `⋮ N more` hides this entry: the click scrolls it into view, never opens it
3425
+ # `⋮ N more` hides this entry: the click scrolls it into view, never opens it
3426
+ self.release_focus()
1701
3427
  elif entries[index].get('closable') and _x >= max(1, self.size[0] - 4):
1702
3428
  self.request_close(entries[index])
1703
3429
  elif (entries[index].get('closable') and R.eligible(entries[index])
1704
3430
  and _x >= max(1, self.size[0] - 8)):
1705
3431
  self.start_registration(entries[index])
3432
+ elif entries[index].get('divider'):
3433
+ self.release_focus()
1706
3434
  else:
1707
3435
  self.open_lane(entries[index])
3436
+ else:
3437
+ self.release_focus()
1708
3438
  i += end + 2
1709
3439
  self.dirty = True
1710
3440
  continue
@@ -1716,48 +3446,43 @@ class Sidebar:
1716
3446
  if entries:
1717
3447
  self.open_lane(entries[self.cursor])
1718
3448
  i += 3
1719
- elif rest[:2] == '[Z': # Shift-Tab: the previous lane that needs you (a prompt keeps it)
1720
- if not (self.renaming or self.promoting):
1721
- self.jump_needs_you(-1)
3449
+ elif rest[:2] == '[Z': # Shift-Tab: the previous lane that needs you (a name field took it above)
3450
+ self.jump_needs_you(-1)
1722
3451
  i += 3
1723
3452
  elif rest[:2] == '[5':
1724
3453
  self.cursor -= 10; i += 4
1725
3454
  elif rest[:2] == '[6':
1726
3455
  self.cursor += 10; i += 4
3456
+ elif rest[:1] == 'O' and len(rest) > 1:
3457
+ # Any other SS3 key — F1–F4 (`ESC O P`…`S` on xterm), application-mode Home / End / ← — is ONE key
3458
+ # (key_length) that does nothing here, like its CSI form below. It used to fall through to the bare
3459
+ # Esc: the keyboard went back to the lane and an open filter was cleared (cockpit v3 S10, DEF-1770).
3460
+ i += 3
1727
3461
  elif rest[:1] == '[': # any other CSI: skip to its final byte
1728
3462
  end = next((k for k, c in enumerate(rest[1:], 1) if '@' <= c <= '~'), len(rest) - 1)
1729
3463
  i += end + 2
1730
3464
  else:
3465
+ # A bare Esc: cancel whatever is open, clear the filter, and give the keyboard back (S3). A cancel,
3466
+ # not an action (begin_action): ` Cancelled` stays; a message it finds lasts its MESSAGE_S. An open
3467
+ # prompt was cancelled above (prompt_sequence).
1731
3468
  if self.registration:
1732
3469
  self.cancel_registration()
1733
- if self.armed:
1734
- self.armed = False
1735
- self.focus_lane()
1736
- if self.renaming:
1737
- self.renaming, self.rename_entry, self.rename_value = False, None, ''
1738
- self.message = 'Cancelled'
1739
- elif self.promoting:
1740
- self.promoting, self.promote_entry = False, None
1741
- self.message = 'Cancelled'
3470
+ self.armed = False
1742
3471
  self.filter, self.filtering = '', False
3472
+ self.release_focus()
1743
3473
  i += 1
1744
3474
  self.dirty = True
1745
3475
  continue
1746
- if self.renaming:
1747
- if ch in '\r\n':
3476
+ if self.prompt: # a name field (a confirmation was answered above): `R`, `P`'s first stage
3477
+ prompt = self.prompt
3478
+ if ch in '\r\n' and prompt['verb'] == 'rename':
1748
3479
  self.commit_rename()
3480
+ elif ch in '\r\n':
3481
+ self.accept_promote_name()
1749
3482
  elif ch in '\x7f\x08':
1750
- self.rename_value = self.rename_value[:-1]
1751
- elif ch == '\x1b': # handled above; here for completeness
1752
- pass
3483
+ prompt['value'] = prompt['value'][:-1]
1753
3484
  elif ch.isprintable():
1754
- self.rename_value += ch
1755
- elif self.promoting:
1756
- if ch in 'yY':
1757
- self.commit_promote()
1758
- else:
1759
- self.promoting, self.promote_entry = False, None
1760
- self.message = 'Cancelled'
3485
+ prompt['value'] += ch
1761
3486
  elif self.filtering:
1762
3487
  if ch in '\r\n':
1763
3488
  self.filtering = False
@@ -1794,9 +3519,11 @@ class Sidebar:
1794
3519
  elif ch == 'G':
1795
3520
  self.cursor = len(entries) - 1
1796
3521
  elif ch == '/':
3522
+ self.begin_action()
1797
3523
  self.filtering, self.filter = True, ''
1798
3524
  self.set_cursor(0)
1799
3525
  elif ch == 'U' and not self.attach_only:
3526
+ self.begin_action()
1800
3527
  self.armed = True
1801
3528
  elif ch == '+' and not self.registration and not self.attach_only:
1802
3529
  self.new_shell()
@@ -1804,28 +3531,63 @@ class Sidebar:
1804
3531
  self.start_registration(entries[self.cursor])
1805
3532
  elif ch == 'x' and entries:
1806
3533
  self.request_close(entries[min(self.cursor, len(entries) - 1)])
3534
+ elif ch == 'u' and entries:
3535
+ self.unregister_lane(entries[min(self.cursor, len(entries) - 1)])
3536
+ elif ch == 'z':
3537
+ self.undo_unregister()
1807
3538
  elif ch == 'R' and entries:
1808
3539
  entry = entries[min(self.cursor, len(entries) - 1)]
1809
3540
  self.start_rename(entry)
3541
+ elif ch == 'p' and entries: # cockpit v3 R5: pins and hidden lanes, the owner's profile
3542
+ self.pin_lane(entries[min(self.cursor, len(entries) - 1)])
3543
+ elif ch == 'h' and entries:
3544
+ self.hide_lane(entries[min(self.cursor, len(entries) - 1)])
3545
+ elif ch == 'H':
3546
+ self.request_hide_workspace()
3547
+ elif ch == 'o':
3548
+ self.toggle_hidden()
1810
3549
  elif ch == 'P' and entries:
1811
3550
  entry = entries[min(self.cursor, len(entries) - 1)]
1812
3551
  self.start_promote(entry)
1813
3552
  elif ch == 'r':
1814
- self.reload(force=True)
3553
+ self.begin_action()
3554
+ if self.update_ready: # the footer offers the update (W9 U6): rebuild this view on its release
3555
+ self.rebuild_view()
3556
+ else:
3557
+ self.reload(force=True)
3558
+ self.release_focus()
1815
3559
  elif ch == '?':
3560
+ self.begin_action()
1816
3561
  self.showing_keys = True
1817
3562
  self.dirty = True
1818
3563
  i += 1
1819
3564
 
1820
3565
  def start_bytes(self):
1821
- """What run() writes once at start: alternate screen, mouse, bracketed paste — and, under LANES_THEME=auto, the
1822
- ONE OSC 11 background query (spec §2.4: no re-ask; handle() parses the answer, the grace starts now)."""
1823
- out = '\033[?1049h\033[?1000h\033[?1006h\033[?2004h\033[2J'
3566
+ """What run() writes once at start: alternate screen, mouse, bracketed paste, focus reports (`?1004h`, cockpit
3567
+ v3 S4) — and, under LANES_THEME=auto, the ONE OSC 11 background query (spec §2.4: no re-ask; handle() parses
3568
+ the answer, the grace starts now)."""
3569
+ out = '\033[?1049h\033[?1000h\033[?1006h\033[?2004h\033[?1004h\033[2J'
1824
3570
  if self.theme_pending:
1825
3571
  out += OSC11_QUERY
1826
3572
  self.theme_deadline = time.monotonic() + THEME_GRACE
1827
3573
  return out
1828
3574
 
3575
+ @staticmethod
3576
+ def stop_bytes():
3577
+ """What run() writes on the way out: every mode start_bytes() turned on, off again, and the cursor back."""
3578
+ return '\033[?1004l\033[?2004l\033[?1006l\033[?1000l\033[?1049l\033[?25h'
3579
+
3580
+ def on_read(self, data):
3581
+ """One read of input in run(). The reports come out first (take_reports), then a sidebar command written since
3582
+ the last tick is taken (poll_command: the chord's floating pane has exited by the time a key gets here), then
3583
+ the keys. A read clears no footer message (cockpit v3 S6): it lasts MESSAGE_S (expire_message, on run()'s
3584
+ tick) or until the next action begins (begin_action). Until S6 the next read that proved the focus cleared it
3585
+ (S4 review, DEF-1815), so a cursor move wiped the reason a close failed (DEF-1822); before S4 every read did,
3586
+ and Zellij's `ESC [ O` after each hand_off() wiped ` Closed sh` as soon as it was painted."""
3587
+ data = self.take_reports(data)
3588
+ self.poll_command()
3589
+ self.handle_input(data)
3590
+
1829
3591
  def run(self):
1830
3592
  fd = sys.stdin.fileno()
1831
3593
  saved = termios.tcgetattr(fd)
@@ -1833,14 +3595,15 @@ class Sidebar:
1833
3595
  tty.setraw(fd)
1834
3596
  sys.stdout.write(self.start_bytes())
1835
3597
  self.reload(force=True)
1836
- target = self.current_target()
1837
- for index, entry in enumerate(self.visible()):
1838
- if entry['id'] == target:
1839
- self.cursor = index
3598
+ self.start_cursor()
1840
3599
  last_size = None
1841
3600
  while True:
1842
3601
  self.reload()
1843
3602
  self.poll_mobile_view()
3603
+ # The worker's answers (cockpit v3 S9): a drain, never a wait — taken after reload(), so an answer is
3604
+ # applied to the listing it changes, and before expire_pending(), which holds a `P` whose write is out.
3605
+ if self.finish_actions():
3606
+ self.dirty = True
1844
3607
  try:
1845
3608
  size = tuple(os.get_terminal_size(sys.stdout.fileno()))
1846
3609
  except OSError:
@@ -1849,10 +3612,21 @@ class Sidebar:
1849
3612
  last_size, self.dirty = size, True
1850
3613
  self.poll_upload()
1851
3614
  self.poll_health()
3615
+ self.poll_update()
1852
3616
  self.poll_economy()
1853
3617
  # An upload in flight no longer forces a frame: poll_upload() dirties on progress and on the end.
1854
3618
  if self.armed or (self.upload_note and time.time() - self.upload_note_at < 9):
1855
3619
  self.dirty = True
3620
+ if self.legend_stale(): # the no-report focus legend ran out (cockpit v3 S4)
3621
+ self.dirty = True
3622
+ if self.expire_message(): # the last message is MESSAGE_S old (cockpit v3 S6)
3623
+ self.dirty = True
3624
+ if self.expire_undo(): # the undo offer is UNDO_S old (cockpit v3 S7)
3625
+ self.dirty = True
3626
+ if self.expire_pending(): # a `P` the registry has not listed PENDING_S on (cockpit v3 S8)
3627
+ self.dirty = True
3628
+ self.expire_theme() # the OSC 11 grace ran out: resolve and publish, frame or not (DEF-1775)
3629
+ self.poll_profile() # a mark the owner's busy profile dropped, every PROFILE_RETRY_S (R3)
1856
3630
  if self.dirty:
1857
3631
  self.render()
1858
3632
  ready, _, _ = select.select([fd], [], [], self.tick_timeout())
@@ -1861,10 +3635,9 @@ class Sidebar:
1861
3635
  if not data:
1862
3636
  time.sleep(1) # pty closed: let the wrapper loop decide
1863
3637
  return
1864
- self.message = ''
1865
- self.handle(data.decode('utf-8', 'replace'))
3638
+ self.on_read(data.decode('utf-8', 'replace'))
1866
3639
  finally:
1867
- sys.stdout.write('\033[?2004l\033[?1006l\033[?1000l\033[?1049l\033[?25h')
3640
+ sys.stdout.write(self.stop_bytes())
1868
3641
  sys.stdout.flush()
1869
3642
  termios.tcsetattr(fd, termios.TCSADRAIN, saved)
1870
3643
 
@@ -1872,5 +3645,6 @@ class Sidebar:
1872
3645
  if __name__ == '__main__':
1873
3646
  if len(sys.argv) < 3:
1874
3647
  sys.exit('usage: sidebar.py <device> <workspace> [slot]')
3648
+ L.record_tree('sidebar') # W9 U0: the tree the most recently started sidebar runs (`lanes version`, doctor)
1875
3649
  signal.signal(signal.SIGINT, signal.SIG_IGN)
1876
3650
  Sidebar(*sys.argv[1:5]).run()