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