@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.
- package/README.md +36 -5
- package/bin/lanes-install.mjs +166 -13
- package/lib/plan.mjs +113 -3
- package/package.json +1 -1
- package/payload/lanes/README.md +47 -23
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2753 -202
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
- package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
- package/payload/lanes/client/lanes-launch +289 -40
- package/payload/lanes/client/lanes-window +15 -3
- package/payload/lanes/container/hooks/claude-settings.json +1 -0
- package/payload/lanes/container/lane +147 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1150 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1318 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +793 -16
- package/payload/lanes/docs/DESIGN.md +127 -46
- package/payload/lanes/docs/INSTALL.md +175 -18
- package/payload/lanes/docs/RUNBOOK.md +968 -76
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +967 -150
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +197 -7
- package/payload/lanes/install/services.py +148 -18
- package/payload/lanes/install.sh +68 -7
- package/payload/lanes/libexec/agent_state.py +74 -1
- package/payload/lanes/libexec/cursor_sync.py +6 -0
- package/payload/lanes/libexec/device_sync.py +47 -10
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/economy_poll.py +1 -0
- package/payload/lanes/libexec/enrold.py +41 -22
- package/payload/lanes/libexec/keys.py +54 -23
- package/payload/lanes/libexec/lanes_lib.py +620 -22
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +21 -2
- package/payload/lanes/libexec/notify.py +169 -29
- package/payload/lanes/libexec/registrations.py +216 -18
- package/payload/lanes/libexec/session_actions.py +182 -14
- package/payload/lanes/libexec/sidebar +11 -3
- package/payload/lanes/libexec/sidebar.py +2232 -365
- package/payload/lanes/libexec/statusd.py +231 -12
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +148 -35
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +1044 -0
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/systemd/lanes-ensure.service +3 -0
- package/payload/lanes/systemd/lanes-gc.service +1 -1
- package/payload/lanes/systemd/lanes-update.service +17 -0
- package/payload/lanes/systemd/lanes-update.timer +17 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +165 -28
- package/payload/lanes/web/browser.js +4 -8
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +45 -32
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- 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 (
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
The keyboard goes back to the lane after every action (cockpit v3 S3): the sidebar and the lane share one keyboard, so
|
|
13
|
+
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
|
-
|
|
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 =
|
|
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
|
|
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)
|
|
280
|
-
#
|
|
281
|
-
#
|
|
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
|
|
284
|
-
('
|
|
285
|
-
('
|
|
286
|
-
('+', '+', '
|
|
287
|
-
('
|
|
288
|
-
('
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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.
|
|
364
|
-
self.
|
|
365
|
-
self.
|
|
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:
|
|
376
|
-
|
|
377
|
-
#
|
|
378
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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
|
|
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
|
-
#
|
|
454
|
-
|
|
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
|
|
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
|
-
|
|
465
|
-
self.color, self.glyph_mode
|
|
466
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
493
|
-
|
|
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.
|
|
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
|
|
499
|
-
"""clients/<device
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
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,
|
|
613
|
-
sections, the counts and the header all read this, so a render looks each lane up once (spec
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
|
|
872
|
-
|
|
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
|
-
|
|
875
|
-
else:
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
882
|
-
|
|
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
|
|
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,
|
|
908
|
-
|
|
909
|
-
|
|
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
|
|
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
|
|
963
|
-
|
|
964
|
-
(spec §4.9
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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.
|
|
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
|
-
|
|
978
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
self.
|
|
1068
|
-
|
|
1069
|
-
|
|
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()
|
|
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.
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
self.
|
|
1086
|
-
self.
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
self.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
if not
|
|
1106
|
-
self.
|
|
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
|
-
|
|
1113
|
-
|
|
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
|
|
1141
|
-
|
|
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.
|
|
1155
|
-
|
|
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
|
-
"""
|
|
1160
|
-
|
|
1161
|
-
|
|
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.
|
|
2226
|
+
self.release_focus()
|
|
1164
2227
|
return
|
|
1165
2228
|
try:
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
1199
|
-
self.
|
|
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
|
-
|
|
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.
|
|
1210
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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 -
|
|
1360
|
-
lines.append(f'{dim} {key
|
|
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
|
-
|
|
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
|
-
|
|
1391
|
-
|
|
1392
|
-
self.
|
|
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.
|
|
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 =
|
|
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"]} {
|
|
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
|
-
|
|
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}
|
|
1458
|
-
|
|
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)}
|
|
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
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
#
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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
|
|
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
|
-
|
|
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=''
|
|
1525
|
-
value, _ = clip(value, max(1, cols -
|
|
1526
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1574
|
-
|
|
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
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
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
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1652
|
-
#
|
|
1653
|
-
#
|
|
1654
|
-
#
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1720
|
-
|
|
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
|
-
|
|
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.
|
|
1747
|
-
|
|
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
|
-
|
|
1751
|
-
elif ch == '\x1b': # handled above; here for completeness
|
|
1752
|
-
pass
|
|
3574
|
+
prompt['value'] = prompt['value'][:-1]
|
|
1753
3575
|
elif ch.isprintable():
|
|
1754
|
-
|
|
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.
|
|
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
|
|
1822
|
-
ONE OSC 11 background query (spec §2.4: no re-ask; handle() parses
|
|
1823
|
-
|
|
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
|
-
|
|
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.
|
|
1865
|
-
self.handle(data.decode('utf-8', 'replace'))
|
|
3731
|
+
self.on_read(data.decode('utf-8', 'replace'))
|
|
1866
3732
|
finally:
|
|
1867
|
-
sys.stdout.write(
|
|
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()
|