@vibelord/lanes 1.1.1 → 1.3.0

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