@vibelord/lanes 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/lanes/README.md +92 -7
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +928 -150
- package/payload/lanes/client/ghostty/common.conf +3 -0
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
- package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
- package/payload/lanes/client/ghostty/omarchy.conf +3 -2
- package/payload/lanes/client/lanes-launch +62 -5
- package/payload/lanes/client/lanes-window +17 -1
- package/payload/lanes/container/env.sh +8 -0
- package/payload/lanes/container/hooks/claude-settings.json +10 -0
- package/payload/lanes/container/hooks/codex-hooks.json +22 -0
- package/payload/lanes/container/lane +326 -25
- package/payload/lanes/container/lane-signal +173 -0
- package/payload/lanes/docs/ARCHITECTURE.md +158 -6
- package/payload/lanes/docs/DESIGN.md +250 -0
- package/payload/lanes/docs/INSTALL.md +39 -0
- package/payload/lanes/docs/RUNBOOK.md +507 -24
- package/payload/lanes/docs/SHARING.md +1 -1
- package/payload/lanes/holders/exec.sh +2 -5
- package/payload/lanes/holders/ssh.sh +18 -0
- package/payload/lanes/holders.lock +3 -0
- package/payload/lanes/install/enable-phone.sh +15 -6
- package/payload/lanes/install/enable-web.sh +19 -6
- package/payload/lanes/install/host-install.sh +10 -1
- package/payload/lanes/install/services.py +30 -3
- package/payload/lanes/install.sh +108 -3
- package/payload/lanes/libexec/agent_state.py +246 -0
- package/payload/lanes/libexec/connection.py +178 -0
- package/payload/lanes/libexec/cursor_sync.py +147 -6
- package/payload/lanes/libexec/device_sync.py +134 -0
- package/payload/lanes/libexec/economy_poll.py +685 -0
- package/payload/lanes/libexec/guard.py +27 -1
- package/payload/lanes/libexec/lanes_lib.py +436 -36
- package/payload/lanes/libexec/notify.py +317 -0
- package/payload/lanes/libexec/registrations.py +148 -0
- package/payload/lanes/libexec/session_actions.py +53 -22
- package/payload/lanes/libexec/sidebar.py +1190 -186
- package/payload/lanes/libexec/statusd.py +352 -90
- package/payload/lanes/libexec/tokens.py +310 -0
- package/payload/lanes/libexec/tokens.sh +11 -0
- package/payload/lanes/libexec/view.sh +22 -11
- package/payload/lanes/systemd/lanes-economy.service +20 -0
- package/payload/lanes/systemd/lanes-notify.service +19 -0
- package/payload/lanes/systemd/lanes-sync.service +3 -0
- package/payload/lanes/web/README.md +96 -2
- package/payload/lanes/web/browser.css +22 -0
- package/payload/lanes/web/browser.js +73 -0
- package/payload/lanes/web/index.html +2 -1
- package/payload/lanes/web/mobile.css +108 -221
- package/payload/lanes/web/mobile.js +105 -55
- package/payload/lanes/workspaces.example/README.md +9 -3
- package/payload/lanes/zellij/config.kdl +128 -19
- package/payload/manifest.json +7 -3
- package/payload/lanes/libexec/legacy_sessions.py +0 -130
|
@@ -5,10 +5,18 @@ 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)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
2 / 3 open the lane under the cursor in the 2nd / 3rd viewport (wide view sessions) · x / × close (asks y/n)
|
|
9
|
+
? shows every key in the body until the next key · Tab / Shift-Tab open the next / previous lane that needs you
|
|
10
|
+
Attention first: lanes whose agent needs you, is working, or finished unseen sort into sections above the rest; the
|
|
11
|
+
agent glyph sits in column 2, and every row carries an age (agent `since`, else last output). The cursor is a LANE:
|
|
12
|
+
when a lane changes section the cursor goes with it. The pane title (OSC 2) carries the header counts, and a lane that
|
|
13
|
+
starts needing you raises one desktop notification (OSC 9), both inside the frame's one write — no fork.
|
|
14
|
+
Upload strip (one row above the footer): click it or press U (Ctrl-Alt-u from anywhere), then DROP FILES anywhere
|
|
15
|
+
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
|
+
focus first; the file itself is then sent by the upload helper on your machine (it only reads folders you allowed
|
|
17
|
+
there). Below it, when there is room, the strip row: the fleet economy line from the opt-in economy.json
|
|
18
|
+
(`$8.5k/16k 53% · F2 S1 · spill 76%`), else which OTHER workspaces need you (`◆1 Worlds`). Column 34 of a lane row is
|
|
19
|
+
one tail cell by priority: stale `?` > starving ⚠ > downgraded ↓ > mostly paid `$`.
|
|
12
20
|
Truthful under load: a lane statusd could not observe keeps its last glyph, dimmed, with a `?`; the footer line warns
|
|
13
21
|
when the host is under pressure or statusd has fallen behind (age of $STATE/health, measured with our own clock).
|
|
14
22
|
Run via libexec/sidebar (a loop), so it never leaves its pane empty.
|
|
@@ -23,6 +31,7 @@ import subprocess
|
|
|
23
31
|
import sys
|
|
24
32
|
import termios
|
|
25
33
|
import time
|
|
34
|
+
from operator import itemgetter
|
|
26
35
|
from pathlib import Path
|
|
27
36
|
import tty
|
|
28
37
|
import unicodedata
|
|
@@ -30,12 +39,125 @@ import unicodedata
|
|
|
30
39
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
40
|
import lanes_lib as L # noqa: E402
|
|
32
41
|
import session_actions as A # noqa: E402
|
|
42
|
+
import registrations as R # noqa: E402
|
|
43
|
+
import tokens # noqa: E402
|
|
33
44
|
|
|
34
45
|
STATE = os.environ.get('LANES_STATE') or os.path.expanduser('~/.local/state/lanes')
|
|
46
|
+
# What the harness in a lane is doing (statusd's additive `agent{}`, Task 4/5 vocabulary): `key -> (unicode, ascii,
|
|
47
|
+
# tone)` from the one token source, drawn in column 2 (spec §3). A SEPARATE dict: GLYPHS' keys stay statusd's words.
|
|
48
|
+
AGENT_GLYPHS = tokens.AGENT_GLYPHS
|
|
49
|
+
# The attention queue (spec §4.4): (section key, title, the agent status whose glyph heads the section, members in
|
|
50
|
+
# their order of urgency). Needs you = a blocking prompt, a failure, a finished turn waiting for a prompt.
|
|
51
|
+
ATTENTION = (('needs', 'Needs you', 'needs_approval', ('needs_approval', 'errored', 'needs_input')),
|
|
52
|
+
('working', 'Working', 'working', ('working',)),
|
|
53
|
+
('done', 'Done', 'done', ('done',)))
|
|
54
|
+
HEADER_COUNTS = ('needs_approval', 'needs_input', 'working', 'done') # the header's glyph counts (spec §4.3)
|
|
55
|
+
SECTION_OF = {status: key for key, _title, _glyph, members in ATTENTION for status in members}
|
|
56
|
+
NEEDS_RANK = {status: rank for rank, status in enumerate(ATTENTION[0][3])}
|
|
57
|
+
INF = float('inf')
|
|
58
|
+
# Tones by role name. Each Sidebar resolves its own table per device (`apply_look`, spec §2.4); this 16-colour table
|
|
59
|
+
# (the user's own palette) is only the class default for a Sidebar built without `__init__`.
|
|
60
|
+
TONE16 = tokens.sgr_table('16', 'dark')
|
|
61
|
+
THEME_GRACE = 0.3 # seconds to wait for the OSC 11 answer before painting dark (spec §2.4)
|
|
62
|
+
OSC11_QUERY = '\033]11;?\033\\' # "what is your background colour?", written once at start (start_bytes)
|
|
63
|
+
# One complete OSC sequence (ST- or BEL-terminated). handle() strips these before its key loop: a reply is never an Esc.
|
|
64
|
+
OSC = re.compile(r'\033\]([^\033\007]*)(?:\033\\|\007)')
|
|
65
|
+
# The only incomplete OSC worth holding across reads: a prefix of the OSC 11 answer this sidebar asked for. Anything
|
|
66
|
+
# else (an operator's Esc then `]`) is input as before, so a held tail can never swallow later keys.
|
|
67
|
+
# Agent statuses that put a lane in Needs you: Tab cycles them, and entering one raises one OSC 9 (spec §4.5, §4.9).
|
|
68
|
+
NEEDS_YOU = ATTENTION[0][3]
|
|
69
|
+
# What may not ride inside an OSC string: C0, DEL and C1 (an ESC or BEL would end it and start a sequence of its own).
|
|
70
|
+
OSC_UNSAFE = re.compile(r'[\x00-\x1f\x7f-\x9f]')
|
|
71
|
+
OSC_TEXT_MAX = 200 # characters of one title / notification payload
|
|
72
|
+
OSC11_PREFIX = re.compile(r'\033\](?:1(?:1(?:;(?:r(?:g(?:b(?::[0-9A-Fa-f/]{0,14})?)?)?)?)?)?)?(?:\033)?$')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def age3(seconds):
|
|
76
|
+
"""Three cells, right-aligned: how long. None/negative = nothing known = three blanks (spec §4.2)."""
|
|
77
|
+
if seconds is None or seconds < 0:
|
|
78
|
+
return ' '
|
|
79
|
+
if seconds < 60:
|
|
80
|
+
return f'{int(seconds):2d}s'
|
|
81
|
+
if seconds < 3600:
|
|
82
|
+
return f'{int(seconds // 60):2d}m'
|
|
83
|
+
if seconds < 86400:
|
|
84
|
+
return f'{int(seconds // 3600):2d}h'
|
|
85
|
+
return f'{min(99, int(seconds // 86400)):2d}d'
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def osc_text(text):
|
|
89
|
+
"""A string fit for an OSC payload: control characters dropped, at most OSC_TEXT_MAX characters (spec §4.9)."""
|
|
90
|
+
return OSC_UNSAFE.sub('', str(text))[:OSC_TEXT_MAX]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ----------------------------------------------------------------- economy (plan Task 12, spec §4.2/§4.6(c))
|
|
94
|
+
ECONOMY_SCHEMA = 1 # economy.json `v` this reader understands (Task 11, DM-LANES-ECONOMY; additive)
|
|
95
|
+
STARVING_AGE = 600 # seconds without a request while the pane is producing output
|
|
96
|
+
# economy.json older than this by its own `at`, measured with OUR clock, is a frozen picture (DEF-1447): the poller
|
|
97
|
+
# rewrites the file at least every economy_poll.HEARTBEAT (60 s) even when nothing changed, so three missed heartbeats
|
|
98
|
+
# mean the poller stopped (crashed, disabled, or a host too loaded to run it) — the same own-clock rule as HEALTH_STALE.
|
|
99
|
+
ECONOMY_STALE = 180
|
|
100
|
+
# Tier letters, in strip order (spec §3/§10: the strip row and the Task 15 preview only — never a row at 36 cols).
|
|
101
|
+
# Plain letters, not table glyphs: the same in every glyph mode. The model name decides; an unknown model has none.
|
|
102
|
+
TIERS = (('fable', 'F'), ('opus', 'O'), ('sonnet', 'S'), ('haiku', 'H'), ('gpt', 'G'))
|
|
103
|
+
SPILL_QUIET = ('closed',) # Throttle `pressure.phase` words (verbatim, DM-LANES-ECONOMY) that are not news
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def number(value):
|
|
107
|
+
"""value when it is a real number (bool is not), else None: economy.json is a projection, never trusted blindly."""
|
|
108
|
+
return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def starving(lane_status, lane_econ):
|
|
112
|
+
"""True when a lane is producing output (`state == 'active'`) while the proxy says it is not being served: no
|
|
113
|
+
request for more than STARVING_AGE s, or a 429 in the daemon's recent-request ring (spec §2.3, plan Task 12).
|
|
114
|
+
`last_request_age_s` is the poller's own measurement at economy.json's `at`; it is NOT aged by the sidebar's clock,
|
|
115
|
+
so a stopped poller freezes the cell instead of turning every active lane into ⚠ — and tail_for() paints a frozen
|
|
116
|
+
figure (a lane record with `stale: true`, or a document past ECONOMY_STALE) ink-dim, not as a live alarm."""
|
|
117
|
+
if not lane_econ or (lane_status or {}).get('state') != 'active':
|
|
118
|
+
return False
|
|
119
|
+
age, refused = number(lane_econ.get('last_request_age_s')), number(lane_econ.get('status_429'))
|
|
120
|
+
return (age is not None and age > STARVING_AGE) or (refused is not None and refused > 0)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def compact_usd(value):
|
|
124
|
+
"""Dollars in at most five cells for the strip: 950, 8.5k, 16k, 123k, 1.2M (no trailing `.0`)."""
|
|
125
|
+
value = max(0.0, float(value))
|
|
126
|
+
if value < 999.5:
|
|
127
|
+
return f'{value:.0f}'
|
|
128
|
+
scaled, suffix = (value / 1e3, 'k') if value < 999_500 else (value / 1e6, 'M')
|
|
129
|
+
text = f'{scaled:.1f}' if scaled < 9.95 else f'{scaled:.0f}'
|
|
130
|
+
return text.removesuffix('.0') + suffix
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def tier_of(model):
|
|
134
|
+
"""The tier letter of a model id (`claude-fable-5-1` -> `F`), or '' when no tier is known."""
|
|
135
|
+
model = model.lower() if isinstance(model, str) else ''
|
|
136
|
+
return next((letter for word, letter in TIERS if word in model), '')
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_economy(doc):
|
|
140
|
+
"""economy.json as the sidebar uses it: the document when it is a schema-1 object, else {} (a document of another
|
|
141
|
+
version, or not an object, is ignored rather than half-read). `fleet` and `lanes` are always dicts afterwards."""
|
|
142
|
+
if not isinstance(doc, dict) or doc.get('v') != ECONOMY_SCHEMA:
|
|
143
|
+
return {}
|
|
144
|
+
fleet, lanes = doc.get('fleet'), doc.get('lanes')
|
|
145
|
+
return dict(doc, fleet=fleet if isinstance(fleet, dict) else {},
|
|
146
|
+
lanes={k: v for k, v in lanes.items() if isinstance(v, dict)} if isinstance(lanes, dict) else {})
|
|
147
|
+
|
|
148
|
+
|
|
35
149
|
# The six words statusd publishes (alignment contract A3) and the helper word for an `exec` lane. There is no
|
|
36
|
-
# seventh: statusd's zmx observer used to publish `idle`, which no other surface knew (DEF-823).
|
|
37
|
-
|
|
38
|
-
|
|
150
|
+
# seventh: statusd's zmx observer used to publish `idle`, which no other surface knew (DEF-823). Values are
|
|
151
|
+
# (unicode, ascii, tone) from the one token source — the tone is a role name from tokens.ROLES, never an SGR code:
|
|
152
|
+
# the device's mode decides the bytes (`apply_look`). The keys stay pinned by the vocabulary gate.
|
|
153
|
+
GLYPHS = tokens.GLYPHS
|
|
154
|
+
GLYPHS_ASCII = {key: value[1] for key, value in GLYPHS.items()}
|
|
155
|
+
MARKS = tokens.MARKS
|
|
156
|
+
ECONOMY_TAIL = tokens.ECONOMY_TAIL
|
|
157
|
+
MOBILE_AGENT_LABELS = tokens.MOBILE_AGENT_LABELS
|
|
158
|
+
# The phone header's words after each glyph count (spec §4.10): ` ◆1 need approval · ◇2 need you · ▸6 working`.
|
|
159
|
+
# Keyed by HEADER_COUNTS, the statuses the desktop header counts.
|
|
160
|
+
MOBILE_COUNT_WORDS = {'needs_approval': 'need approval', 'needs_input': 'need you', 'working': 'working', 'done': 'done'}
|
|
39
161
|
# The phone sidebar spells the same six words (plus `helper`) in full. The keys are GLYPHS's keys and the
|
|
40
162
|
# vocabulary gate pins that: this map kept `idle` for a whole release after DEF-823 removed it from GLYPHS,
|
|
41
163
|
# because the gate read GLYPHS alone and the map was a literal buried inside `render_mobile` (DEF-983).
|
|
@@ -44,6 +166,44 @@ MOBILE_LABELS = {'active': 'Active', 'quiet': 'Quiet', 'missing': 'Container off
|
|
|
44
166
|
'unknown': 'Checking status'}
|
|
45
167
|
|
|
46
168
|
|
|
169
|
+
def resolve_modes(env, meta):
|
|
170
|
+
"""(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)."""
|
|
172
|
+
meta = meta if isinstance(meta, dict) else {}
|
|
173
|
+
if env.get('NO_COLOR'):
|
|
174
|
+
color = 'none'
|
|
175
|
+
else:
|
|
176
|
+
color = env.get('LANES_COLOR') or ('truecolor' if env.get('COLORTERM') in ('truecolor', '24bit') else '16')
|
|
177
|
+
color = meta.get('color') or color
|
|
178
|
+
theme = meta.get('theme') or env.get('LANES_THEME') or 'auto'
|
|
179
|
+
locale = [env.get(k) or '' for k in ('LC_ALL', 'LC_CTYPE', 'LANG')]
|
|
180
|
+
utf8 = any('UTF-8' in value.upper() or 'UTF8' in value.upper() for value in locale)
|
|
181
|
+
glyphs = meta.get('glyphs') or env.get('LANES_GLYPHS') or ('unicode' if utf8 or not any(locale) else 'ascii')
|
|
182
|
+
if color not in ('truecolor', '16', 'none'):
|
|
183
|
+
color = '16'
|
|
184
|
+
if theme not in ('dark', 'light', 'auto'):
|
|
185
|
+
theme = 'auto'
|
|
186
|
+
if glyphs not in ('unicode', 'ascii', 'emoji'):
|
|
187
|
+
glyphs = 'unicode'
|
|
188
|
+
return color, theme, glyphs
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def parse_osc11(body):
|
|
192
|
+
"""'11;rgb:RRRR/GGGG/BBBB' (1- to 4-digit hex components) -> 'dark'|'light' by relative luminance (< 0.5 dark);
|
|
193
|
+
anything else -> None."""
|
|
194
|
+
if not body.startswith('11;rgb:'):
|
|
195
|
+
return None
|
|
196
|
+
parts = body[7:].split('/')
|
|
197
|
+
if len(parts) != 3 or not all(1 <= len(p) <= 4 for p in parts):
|
|
198
|
+
return None
|
|
199
|
+
try:
|
|
200
|
+
channels = [int(p, 16) / (16 ** len(p) - 1) for p in parts]
|
|
201
|
+
except ValueError:
|
|
202
|
+
return None
|
|
203
|
+
lin = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in channels]
|
|
204
|
+
return 'light' if 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2] >= 0.5 else 'dark'
|
|
205
|
+
|
|
206
|
+
|
|
47
207
|
def viewer_mark(status):
|
|
48
208
|
"""The mark after a lane's name when other sessions view it: one dot per viewer, at most three."""
|
|
49
209
|
count = int((status or {}).get('viewers') or 0)
|
|
@@ -66,6 +226,8 @@ def mtime(path):
|
|
|
66
226
|
|
|
67
227
|
|
|
68
228
|
def cell_width(text):
|
|
229
|
+
if text.isascii(): # every ASCII character is one cell here (none combines, none is wide): the common case
|
|
230
|
+
return len(text)
|
|
69
231
|
width = 0
|
|
70
232
|
for ch in text:
|
|
71
233
|
if unicodedata.combining(ch) or ch in '️':
|
|
@@ -75,6 +237,9 @@ def cell_width(text):
|
|
|
75
237
|
|
|
76
238
|
|
|
77
239
|
def clip(text, width):
|
|
240
|
+
if text.isascii(): # same answer as the walk below, without it (spec §4.11: one clip per row, cheap)
|
|
241
|
+
text = text[:max(0, width)]
|
|
242
|
+
return text, len(text)
|
|
78
243
|
out, used = [], 0
|
|
79
244
|
for ch in text:
|
|
80
245
|
w = cell_width(ch)
|
|
@@ -88,14 +253,16 @@ def clip(text, width):
|
|
|
88
253
|
HEALTH_STALE = 30 # seconds; statusd rewrites $STATE/health every cycle, at most ~12 s apart when it slows itself down
|
|
89
254
|
|
|
90
255
|
|
|
91
|
-
def health_banner(health, written, now):
|
|
256
|
+
def health_banner(health, written, now, warning='⚠', sep=' · '):
|
|
92
257
|
"""The footer warning; '' = nothing to say. `health` = L.read_health(), `written` = the file's mtime in seconds
|
|
93
258
|
(0 = no such file: a statusd from before it existed — nothing is known, so nothing is claimed).
|
|
94
259
|
statusd is what freezes on a thrashing host, so the age is measured HERE, with our clock, from the file's mtime.
|
|
95
|
-
The age comes first: a sidebar is 36 cells wide, and "what you see is N minutes old" must survive the clipping.
|
|
260
|
+
The age comes first: a sidebar is 36 cells wide, and "what you see is N minutes old" must survive the clipping.
|
|
261
|
+
`warning` and `sep` are the device's `warning` / ` sep ` marks (spec §4.6(a)); the defaults are the unicode ones,
|
|
262
|
+
so a three-argument call reads exactly as before."""
|
|
96
263
|
if os.path.exists(os.path.join(STATE, 'flags', 'sockets-lost')):
|
|
97
264
|
# Short, and first: it explains every "not running" on the screen. The fix is one command on the server.
|
|
98
|
-
return '
|
|
265
|
+
return f'{warning} lane sockets deleted{sep}lanes run; on the server: lanes sockets repair'
|
|
99
266
|
if not written:
|
|
100
267
|
return ''
|
|
101
268
|
parts, age = [], int(now - written)
|
|
@@ -104,18 +271,76 @@ def health_banner(health, written, now):
|
|
|
104
271
|
level = health.get('level', 'ok')
|
|
105
272
|
if level != 'ok':
|
|
106
273
|
parts.append('host under memory pressure' if level == 'critical' else 'host busy')
|
|
107
|
-
return '
|
|
274
|
+
return warning + ' ' + sep.join(parts) if parts else ''
|
|
108
275
|
|
|
109
276
|
|
|
110
277
|
SLOTS = ['main', 'b', 'c']
|
|
111
|
-
BOX_ROWS = 4
|
|
278
|
+
BOX_ROWS = 1 # the upload strip (spec §4.6(d)); it was a four-row box
|
|
279
|
+
# The `?` key sheet (spec §4.7): (key, its seven-bit twin, action), one line per key handle() accepts. A line is
|
|
280
|
+
# ` {key:<10}{action}`: the key field is 10 cells and an action at most 23, so a line is at most 35 cells — the
|
|
281
|
+
# sidebar's row width with column 36 never written. `Tab`/`Shift-Tab` are Task 25's keys.
|
|
282
|
+
KEY_SHEET = (('↑ ↓ j k', 'Up Dn j k', 'move'), ('⏎ → Space', 'Enter Spc', 'open the cursor lane'),
|
|
283
|
+
('J K', 'J K', 'open next / prev lane'), ('Tab', 'Tab', 'next lane needing you'),
|
|
284
|
+
('Shift-Tab', 'Shift-Tab', 'prev lane needing you'), ('2 3', '2 3', 'show in viewport 2 / 3'),
|
|
285
|
+
('/', '/', 'find a lane'), ('Esc', 'Esc', 'clear / cancel'), ('g G', 'g G', 'first / last'),
|
|
286
|
+
('+', '+', 'new shell'), ('a', 'a', 'add to a workspace'), ('x', 'x', 'close (asks y/n)'),
|
|
287
|
+
('R', 'R', 'rename'), ('P', 'P', 'register a session'), ('U', 'U', 'arm the upload strip'),
|
|
288
|
+
('r', 'r', 'reload'), ('?', '?', 'this sheet'))
|
|
112
289
|
PASTE_START, PASTE_END = '\033[200~', '\033[201~'
|
|
113
290
|
|
|
114
291
|
|
|
292
|
+
def key_length(data, i):
|
|
293
|
+
"""How many characters of `data` from `i` make ONE key: a plain character, a bare Esc, an SS3 key (`\\033OA`), an
|
|
294
|
+
SGR mouse report (`\\033[<b;x;yM`/`m`) or any other CSI up to its final byte (`\\033[A`, `\\033[5~`). Esc followed
|
|
295
|
+
by anything else is a bare Esc, as handle() reads it. An unterminated sequence runs to the end of the read, which
|
|
296
|
+
is how handle() itself drops one."""
|
|
297
|
+
if data[i] != '\033':
|
|
298
|
+
return 1
|
|
299
|
+
rest = data[i + 1:]
|
|
300
|
+
if rest.startswith('[<'):
|
|
301
|
+
end = next((k for k, c in enumerate(rest) if c in 'Mm'), -1)
|
|
302
|
+
elif rest[:1] == '[':
|
|
303
|
+
end = next((k for k, c in enumerate(rest[1:], 1) if '@' <= c <= '~'), -1)
|
|
304
|
+
elif rest[:1] == 'O' and len(rest) > 1:
|
|
305
|
+
return 3
|
|
306
|
+
else:
|
|
307
|
+
return 1
|
|
308
|
+
return len(data) - i if end < 0 else end + 2
|
|
309
|
+
|
|
310
|
+
|
|
115
311
|
class Sidebar:
|
|
312
|
+
# Class-level defaults for the registration state, so a Sidebar built without `__init__` (the
|
|
313
|
+
# alignment gate constructs one with `__new__` and sets only the fields its rule needs) reads the
|
|
314
|
+
# 'no registration in progress' shape rather than raising; `__init__` assigns the live values.
|
|
315
|
+
registration, registration_choices, workspaces = None, (), ()
|
|
316
|
+
renaming, rename_value, rename_entry = False, '', None
|
|
317
|
+
promoting, promote_entry = False, None
|
|
318
|
+
attach_only = False
|
|
319
|
+
confirming = None # the entry `x` / `×` asked to close; `y` closes it, any other key cancels
|
|
320
|
+
seen = {} # read-only default; mark_seen() always assigns a fresh dict
|
|
321
|
+
tone = TONE16
|
|
322
|
+
glyphs = tokens.glyph_set('unicode')
|
|
323
|
+
color, glyph_mode, variant = '16', 'unicode', 'dark'
|
|
324
|
+
theme_pending, theme_deadline, osc_buffer = False, 0.0, ''
|
|
325
|
+
last_visible = () # the list visible() last returned: the one `self.cursor` indexes
|
|
326
|
+
economy = {} # read-only default; reload() assigns the loaded economy.json (load_economy)
|
|
327
|
+
economy_tiers = '' # this workspace's tier mix from economy.json, `F2 S1` (reload)
|
|
328
|
+
more_row = 0 # the 1-based screen row of `⋮ N more` (render), 0 = the list fits
|
|
329
|
+
showing_keys = False # `?` replaces the body with the key sheet until the next key (spec §4.7)
|
|
330
|
+
fleet = {} # other workspaces' title -> {needs_approval: n, needs_input: n} (reload)
|
|
331
|
+
board_titles = {} # workspace id -> title, from the registry (reload)
|
|
332
|
+
strip_row = False # render(): the strip row (economy or fleet attention) is drawn this frame
|
|
333
|
+
last_counts = '' # the header counts the last OSC 2 title carried ('' = none sent; spec §4.9)
|
|
334
|
+
notified = {} # read-only default; __init__ assigns: lane id -> the needs-you status already notified
|
|
335
|
+
|
|
116
336
|
def __init__(self, device, workspace, slot='main', viewports='1'):
|
|
117
337
|
self.device, self.workspace, self.slot = device, workspace, slot
|
|
118
|
-
|
|
338
|
+
# devices/<device>.json: `mobile` is read once here; the look keys (`color`, `theme`, `glyphs`) are re-read
|
|
339
|
+
# whenever the file changes (reload()'s stamp), so `lanes attach DEVICE --ascii` reaches a RUNNING sidebar.
|
|
340
|
+
self.look_path = os.path.join(STATE, 'devices', device + '.json')
|
|
341
|
+
meta = read_json(self.look_path)
|
|
342
|
+
meta = meta if isinstance(meta, dict) else {}
|
|
343
|
+
self.mobile = bool(meta.get('mobile'))
|
|
119
344
|
self.mobile_owner, self.mobile_titles, self.next_mobile_poll = False, {}, 0
|
|
120
345
|
try:
|
|
121
346
|
self.viewports = max(1, min(len(SLOTS), int(viewports)))
|
|
@@ -126,16 +351,35 @@ class Sidebar:
|
|
|
126
351
|
self.pid_file = self.slot_file(slot, 'pid')
|
|
127
352
|
self.registry_path = os.path.join(STATE, 'registry.json')
|
|
128
353
|
self.status_path = os.path.join(STATE, 'status.json')
|
|
354
|
+
self.economy_path = os.path.join(STATE, 'economy.json') # the opt-in poller's projection (Task 11)
|
|
129
355
|
self.health_path = os.path.join(STATE, 'health')
|
|
130
356
|
self.health, self.health_stamp, self.banner = {}, None, ''
|
|
131
357
|
self.entries, self.title, self.errors = [], workspace, []
|
|
358
|
+
self.attach_only = False
|
|
359
|
+
self.workspaces, self.registration, self.registration_choices = [], None, []
|
|
132
360
|
self.status, self.unregistered, self.zmx = {}, [], []
|
|
133
361
|
self.cursor, self.top = 0, 0
|
|
134
362
|
self.filter, self.filtering = '', False
|
|
135
363
|
self.renaming, self.rename_value, self.rename_entry = False, '', None
|
|
136
364
|
self.promoting, self.promote_entry = False, None
|
|
137
|
-
self.
|
|
138
|
-
self.
|
|
365
|
+
self.confirming = None
|
|
366
|
+
self.dirty = True
|
|
367
|
+
self.color, self.glyph_mode, self.variant = None, None, None
|
|
368
|
+
self.theme_pending, self.theme_deadline, self.osc_buffer = False, 0.0, ''
|
|
369
|
+
self.row_cache = {} # lane id -> (key, row text); a look change and `r` empty it
|
|
370
|
+
self.row_hits, self.row_misses = 0, 0
|
|
371
|
+
self.economy, self.economy_tiers = {}, '' # economy.json (reload); tail_for() and the strip read it
|
|
372
|
+
self.economy_frozen = False # economy_aged() at the last poll_economy(): a flip repaints
|
|
373
|
+
self.showing_keys, self.strip_row = False, False
|
|
374
|
+
self.fleet, self.board_titles = {}, {}
|
|
375
|
+
self.last_counts, self.notified = '', {} # '' not None: an agent-less workspace never emits a title
|
|
376
|
+
self.apply_look(meta)
|
|
377
|
+
# `done` is sticky until the lane is opened from this device: lane id -> the time it was last opened. Read
|
|
378
|
+
# once here and again only when a target file changes (never per tick: spec §8's stat budget), written by
|
|
379
|
+
# mark_seen() and by `lanes next|prev`.
|
|
380
|
+
self.seen_path = os.path.join(STATE, 'clients', device + '.seen.json')
|
|
381
|
+
self.seen = L.clean_seen(read_json(self.seen_path))
|
|
382
|
+
self.stamp = (0, 0, 0, 0, 0) # registry, status, targets, device look, economy
|
|
139
383
|
self.size = (0, 0)
|
|
140
384
|
self.dirty = True
|
|
141
385
|
self.message = ''
|
|
@@ -158,34 +402,124 @@ class Sidebar:
|
|
|
158
402
|
# ---------------------------------------------------------------- data
|
|
159
403
|
def reload(self, force=False):
|
|
160
404
|
stamp = (mtime(self.registry_path), mtime(self.status_path),
|
|
161
|
-
sum(mtime(self.slot_file(slot, 'target')) for slot in SLOTS[:self.viewports])
|
|
405
|
+
sum(mtime(self.slot_file(slot, 'target')) for slot in SLOTS[:self.viewports]),
|
|
406
|
+
mtime(self.look_path), mtime(self.economy_path))
|
|
162
407
|
if stamp == self.stamp and not force:
|
|
163
408
|
return
|
|
409
|
+
if force:
|
|
410
|
+
self.row_cache.clear() # `r` and every forced reload repaint every row from scratch (spec §4.11)
|
|
411
|
+
# The cursor follows the LANE, not the index (spec §4.5, §10): a lane that changes section moves rows above
|
|
412
|
+
# the cursor, so remember which lane it is on and re-find it after the reload.
|
|
413
|
+
cursor_id = self.cursor_id
|
|
164
414
|
if stamp[0] != self.stamp[0] or force:
|
|
165
415
|
registry = read_json(self.registry_path) or {}
|
|
166
416
|
boards = registry.get('workspaces', [])
|
|
417
|
+
self.workspaces = boards
|
|
167
418
|
self.mobile_owner = self.mobile and bool(boards) and boards[0]['id'] == self.workspace
|
|
168
419
|
self.mobile_titles = {w.get('title') or w['id']: w['id'] for w in boards}
|
|
420
|
+
self.board_titles = {w['id']: w.get('title') or w['id'] for w in boards}
|
|
169
421
|
self.mobile_titles.update({tool['tab']: tool['view'] for tool in registry.get('tools', [])})
|
|
170
422
|
for workspace in registry.get('workspaces', []):
|
|
171
423
|
if workspace.get('id') == self.workspace:
|
|
172
424
|
self.title = workspace.get('title') or self.workspace
|
|
173
|
-
self.entries = list(workspace.get('lanes', []))
|
|
425
|
+
self.entries = list(workspace.get('lanes', []))
|
|
174
426
|
self.errors = workspace.get('errors', [])
|
|
427
|
+
self.attach_only = bool(workspace.get('attach_only'))
|
|
175
428
|
break
|
|
176
429
|
if stamp[1] != self.stamp[1] or force:
|
|
177
430
|
status = read_json(self.status_path) or {}
|
|
178
431
|
self.status = status.get('lanes', {})
|
|
179
432
|
self.unregistered = (status.get('unregistered') or {}).get(self.workspace, [])
|
|
180
433
|
self.zmx = (status.get('zmx') or {}).get(self.workspace, [])
|
|
434
|
+
if stamp[0] != self.stamp[0] or stamp[1] != self.stamp[1] or force:
|
|
435
|
+
self.fleet = self.fleet_counts()
|
|
436
|
+
if stamp[3] != self.stamp[3] or force:
|
|
437
|
+
self.apply_look(read_json(self.look_path) or {})
|
|
438
|
+
if stamp[4] != self.stamp[4] or force:
|
|
439
|
+
# The seventh stat() of a tick at one viewport (spec §8): re-read only when the poller replaced the file.
|
|
440
|
+
# A missing file (no poller: the default) is {} — no economy tail on any row, the strip row yields.
|
|
441
|
+
self.economy = load_economy(read_json(self.economy_path)) if stamp[4] else {}
|
|
442
|
+
self.economy_tiers = self.tier_mix()
|
|
443
|
+
if stamp[2] != self.stamp[2] and self.stamp != (0, 0, 0, 0, 0): # not the first reload: __init__ read it
|
|
444
|
+
# A viewport was pointed at a lane — by this sidebar, or by `lanes next|prev` (Ctrl-Alt-Down/Up, plan
|
|
445
|
+
# Task 19), which marks the lane seen in the device's file as open_lane does. Merge the file once per
|
|
446
|
+
# such change (never per tick: spec §8), so a `done` lane opened that way leaves Done here too.
|
|
447
|
+
merged = L.clean_seen(read_json(self.seen_path))
|
|
448
|
+
for key, value in self.seen.items():
|
|
449
|
+
merged[key] = max(value, merged.get(key, value))
|
|
450
|
+
self.seen = merged # a fresh dict, never the class default mutated in place
|
|
181
451
|
self.stamp = stamp
|
|
182
452
|
self.dirty = True
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
453
|
+
# `top` is kept (no reset when the list length changes) and shifted by the rows inserted above the cursor, so
|
|
454
|
+
# the cursor's lane stays on its screen row; render() still clamps both on every draw.
|
|
455
|
+
self.resolve_cursor(self.visible(), cursor_id)
|
|
456
|
+
|
|
457
|
+
# ------------------------------------------------------------------ look
|
|
458
|
+
def apply_look(self, meta):
|
|
459
|
+
"""Resolve colour / theme / glyph modes from the device file and the environment (spec §2.4). Runs at start and
|
|
460
|
+
whenever devices/<device>.json changes, so `lanes attach DEVICE --ascii|--theme|--color` against a LIVE session
|
|
461
|
+
reaches every sidebar of that device within one tick (Zellij panes keep the server's environment from session
|
|
462
|
+
creation: an environment variable can never reach a running sidebar). Publishes the glyph mode for view.sh."""
|
|
463
|
+
color, theme, glyphs = resolve_modes(os.environ, meta)
|
|
464
|
+
changed = (color, glyphs) != (self.color, self.glyph_mode)
|
|
465
|
+
self.color, self.glyph_mode = color, glyphs
|
|
466
|
+
variant_changed = False
|
|
467
|
+
if theme != 'auto':
|
|
468
|
+
variant_changed = theme != self.variant
|
|
469
|
+
self.variant, self.theme_pending = theme, False
|
|
470
|
+
elif self.variant is None: # first resolution under `auto`: paint dark, ask the terminal once
|
|
471
|
+
self.variant, self.theme_pending, self.theme_deadline = 'dark', True, time.monotonic() + THEME_GRACE
|
|
472
|
+
changed = changed or variant_changed
|
|
473
|
+
self.tone = tokens.sgr_table(self.color, self.variant)
|
|
474
|
+
self.glyphs = tokens.glyph_set(self.glyph_mode)
|
|
475
|
+
glyphs_file = os.path.join(STATE, 'clients', self.device + '.glyphs')
|
|
476
|
+
if changed or not os.path.exists(glyphs_file):
|
|
477
|
+
try:
|
|
478
|
+
L.atomic_write(Path(glyphs_file), self.glyph_mode + '\n')
|
|
479
|
+
except OSError:
|
|
480
|
+
pass # view.sh then keeps its default; the sidebar itself is unaffected
|
|
481
|
+
if theme != 'auto':
|
|
482
|
+
self.publish_theme(variant_changed)
|
|
483
|
+
if changed:
|
|
484
|
+
self.row_cache.clear()
|
|
485
|
+
self.dirty = True
|
|
486
|
+
|
|
487
|
+
def apply_theme(self, variant):
|
|
488
|
+
"""The OSC 11 answer (or the grace expiry, with the current variant): repaint on a change and publish it."""
|
|
489
|
+
self.theme_pending = False
|
|
490
|
+
if not variant:
|
|
491
|
+
return
|
|
492
|
+
changed = variant != self.variant
|
|
493
|
+
if changed:
|
|
494
|
+
self.variant, self.tone, self.dirty = variant, tokens.sgr_table(self.color, variant), True
|
|
495
|
+
self.row_cache.clear()
|
|
496
|
+
self.publish_theme(changed)
|
|
497
|
+
|
|
498
|
+
def publish_theme(self, changed):
|
|
499
|
+
"""clients/<device>.theme (`dark`/`light` + newline) for `glow`: written on change or when absent, never per
|
|
500
|
+
render."""
|
|
501
|
+
theme_file = os.path.join(STATE, 'clients', self.device + '.theme')
|
|
502
|
+
if changed or not os.path.exists(theme_file):
|
|
503
|
+
try:
|
|
504
|
+
L.atomic_write(Path(theme_file), self.variant + '\n')
|
|
505
|
+
except OSError:
|
|
506
|
+
pass
|
|
507
|
+
|
|
508
|
+
def strip_osc(self, data):
|
|
509
|
+
"""Remove complete OSC sequences from `data`, apply OSC 11 answers, hold an incomplete OSC 11 tail for the next
|
|
510
|
+
read (Review Focus 6). An OSC reply is never an Esc: it cannot cancel a filter or a prompt."""
|
|
511
|
+
data, self.osc_buffer = self.osc_buffer + data, ''
|
|
512
|
+
if '\033]' not in data:
|
|
513
|
+
return data
|
|
514
|
+
for match in OSC.finditer(data):
|
|
515
|
+
variant = parse_osc11(match.group(1))
|
|
516
|
+
if variant:
|
|
517
|
+
self.apply_theme(variant)
|
|
518
|
+
data = OSC.sub('', data)
|
|
519
|
+
held = OSC11_PREFIX.search(data)
|
|
520
|
+
if held:
|
|
521
|
+
self.osc_buffer, data = data[held.start():], data[:held.start()]
|
|
522
|
+
return data
|
|
189
523
|
|
|
190
524
|
def poll_mobile_view(self):
|
|
191
525
|
"""One sidebar observes the phone's web client and the first display of each full-width view.
|
|
@@ -223,10 +557,25 @@ class Sidebar:
|
|
|
223
557
|
stamp = mtime(self.health_path)
|
|
224
558
|
if stamp != self.health_stamp:
|
|
225
559
|
self.health_stamp, self.health = stamp, L.read_health(STATE)
|
|
226
|
-
|
|
560
|
+
marks = self.glyphs['marks']
|
|
561
|
+
banner = health_banner(self.health, stamp / 1e9, time.time(), marks['warning'], f" {marks['sep']} ")
|
|
227
562
|
if banner != self.banner:
|
|
228
563
|
self.banner, self.dirty = banner, True
|
|
229
564
|
|
|
565
|
+
def economy_aged(self, now):
|
|
566
|
+
"""True when the loaded economy.json is older than ECONOMY_STALE by its own `at` against `now` (our clock):
|
|
567
|
+
the poller stopped, so every figure in it is frozen (DEF-1447). False without a document or without a numeric
|
|
568
|
+
`at` — nothing is known, so nothing is claimed (health_banner's rule for a missing health file)."""
|
|
569
|
+
at = number(self.economy.get('at')) if self.economy else None
|
|
570
|
+
return at is not None and now - at > ECONOMY_STALE
|
|
571
|
+
|
|
572
|
+
def poll_economy(self):
|
|
573
|
+
"""Repaint when economy.json crosses ECONOMY_STALE. Polled every tick like poll_health(): the case that matters
|
|
574
|
+
is a poller that stopped, where economy.json's mtime — reload()'s only trigger — never changes again."""
|
|
575
|
+
frozen = self.economy_aged(time.time())
|
|
576
|
+
if frozen != self.economy_frozen:
|
|
577
|
+
self.economy_frozen, self.dirty = frozen, True
|
|
578
|
+
|
|
230
579
|
def current_target(self):
|
|
231
580
|
try:
|
|
232
581
|
with open(self.target_file) as handle:
|
|
@@ -238,16 +587,417 @@ class Sidebar:
|
|
|
238
587
|
except OSError:
|
|
239
588
|
return ''
|
|
240
589
|
|
|
590
|
+
# ------------------------------------------------------------ attention
|
|
591
|
+
def agent_of(self, lane_id):
|
|
592
|
+
"""The lane's `agent{}` from status.json, or {} (a plain shell, or a statusd that does not publish it)."""
|
|
593
|
+
agent = (self.status.get(lane_id) or {}).get('agent')
|
|
594
|
+
return agent if isinstance(agent, dict) else {}
|
|
595
|
+
|
|
596
|
+
def seen_done(self, lane_id):
|
|
597
|
+
"""True when this lane's `done` is not news any more: it was opened at or after the turn finished."""
|
|
598
|
+
since = self.agent_of(lane_id).get('since')
|
|
599
|
+
return lane_id in self.seen and (since if isinstance(since, (int, float)) else 0) <= self.seen[lane_id]
|
|
600
|
+
|
|
601
|
+
def mark_seen(self, lane_id):
|
|
602
|
+
"""Record that the operator opened this lane (only `open_lane` calls it, spec §10): a `done` turn that
|
|
603
|
+
finished before now leaves the Done section; a later one is news again. Persisted per device, bounded to
|
|
604
|
+
the lanes status.json knows, so a restarted sidebar does not resurrect what was already looked at.
|
|
605
|
+
Every workspace tab of a device runs its own sidebar over the SAME file, so a write merges what is on disk
|
|
606
|
+
(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."""
|
|
608
|
+
since = self.agent_of(lane_id).get('since')
|
|
609
|
+
self.seen = L.record_seen(self.seen_path, lane_id, since, self.status, self.seen)
|
|
610
|
+
|
|
611
|
+
def agent_rows(self, lanes):
|
|
612
|
+
"""[(entry, agent status or None, since or None, attention key or None)] in ONE pass over status.json — the
|
|
613
|
+
sections, the counts and the header all read this, so a render looks each lane up once (spec §4.11)."""
|
|
614
|
+
if not lanes:
|
|
615
|
+
return []
|
|
616
|
+
status_of, seen, out = self.status, self.seen, []
|
|
617
|
+
for entry in lanes:
|
|
618
|
+
agent = (status_of.get(entry.get('id', '')) or {}).get('agent')
|
|
619
|
+
status = agent.get('status') if isinstance(agent, dict) else None
|
|
620
|
+
if status not in AGENT_GLYPHS:
|
|
621
|
+
out.append((entry, None, None, None))
|
|
622
|
+
continue
|
|
623
|
+
since = agent.get('since')
|
|
624
|
+
since = since if isinstance(since, (int, float)) else None
|
|
625
|
+
key = SECTION_OF.get(status)
|
|
626
|
+
if key == 'done' and entry['id'] in seen and (since or 0) <= seen[entry['id']]:
|
|
627
|
+
key = None # opened since it finished: not news (seen_done)
|
|
628
|
+
out.append((entry, status, since, key))
|
|
629
|
+
return out
|
|
630
|
+
|
|
631
|
+
def fleet_counts(self):
|
|
632
|
+
"""{other workspace's title: {needs_approval: n, needs_input: n}} from the status.json already loaded — one pass
|
|
633
|
+
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."""
|
|
635
|
+
found = {}
|
|
636
|
+
for lane_id, entry in self.status.items():
|
|
637
|
+
board = lane_id.split('--', 1)[0]
|
|
638
|
+
agent = entry.get('agent') if isinstance(entry, dict) else None
|
|
639
|
+
status = agent.get('status') if isinstance(agent, dict) else None
|
|
640
|
+
if board != self.workspace and status in ('needs_approval', 'needs_input'):
|
|
641
|
+
counts = found.setdefault(board, {})
|
|
642
|
+
counts[status] = counts.get(status, 0) + 1
|
|
643
|
+
order = {board: index for index, board in enumerate(self.board_titles)}
|
|
644
|
+
return {self.board_titles.get(board, board): found[board]
|
|
645
|
+
for board in sorted(found, key=lambda b: (order.get(b, len(order)), b))}
|
|
646
|
+
|
|
647
|
+
def fleet_attention(self, width=None):
|
|
648
|
+
"""The strip row's fleet attention: ` ◆1 Worlds · ◇2 Code`, glyphs in their tones, '' when no other workspace
|
|
649
|
+
needs you. `width` (cells) drops whole workspaces that would not fit, never half a count."""
|
|
650
|
+
glyphs, tone = self.glyphs, self.tone
|
|
651
|
+
sep = f' {glyphs["marks"]["sep"]} '
|
|
652
|
+
out, used = [], 0
|
|
653
|
+
for title, counts in self.fleet.items():
|
|
654
|
+
cells = [(f'{tone[tokens.tone_of(AGENT_GLYPHS, k)]}{glyphs["agent"][k]}{counts[k]}{tone["reset"]}',
|
|
655
|
+
f'{glyphs["agent"][k]}{counts[k]}') for k in ('needs_approval', 'needs_input') if counts.get(k)]
|
|
656
|
+
if not cells:
|
|
657
|
+
continue
|
|
658
|
+
plain = ' '.join(c[1] for c in cells) + ' ' + title
|
|
659
|
+
cost = cell_width((sep if out else ' ') + plain)
|
|
660
|
+
if width is not None and used + cost > width:
|
|
661
|
+
break
|
|
662
|
+
out.append(' '.join(c[0] for c in cells) + ' ' + title)
|
|
663
|
+
used += cost
|
|
664
|
+
return ' ' + sep.join(out) if out else ''
|
|
665
|
+
|
|
666
|
+
def tier_mix(self):
|
|
667
|
+
"""This workspace's lanes in economy.json by model tier, in TIERS order: `F2 S1` ('' when none is known).
|
|
668
|
+
Computed once per economy.json change (reload), never per render."""
|
|
669
|
+
prefix, counts = self.workspace + '--', {}
|
|
670
|
+
for lane_id, econ in (self.economy.get('lanes') or {}).items():
|
|
671
|
+
letter = tier_of(econ.get('model')) if lane_id.startswith(prefix) else ''
|
|
672
|
+
if letter:
|
|
673
|
+
counts[letter] = counts.get(letter, 0) + 1
|
|
674
|
+
return ' '.join(f'{letter}{counts[letter]}' for _word, letter in TIERS if counts.get(letter))
|
|
675
|
+
|
|
676
|
+
def economy_strip(self, width, now):
|
|
677
|
+
"""(painted, plain) of the strip row's fleet economy line (spec §4.6(c)): ` $8.5k/16k 53% · 429 +4 · F2 S1 ·
|
|
678
|
+
spill 76% · free 3/19 · reset 2h`, parts in that priority order, a part only when its facts are known, whole
|
|
679
|
+
parts dropped from the end to fit `width` cells. ('', '') when economy.json knows no fleet fact (no poller,
|
|
680
|
+
or `LANES_THROTTLE_CONTROL=none`): the strip row then yields to fleet attention. Money over the alert level,
|
|
681
|
+
new 429s and a spilling pool are in the attention tone; everything else, and the whole line while the poller
|
|
682
|
+
marks the fleet `stale` (a frozen picture is not live), ink-dim with the stale mark in front."""
|
|
683
|
+
fleet = self.economy.get('fleet') or {}
|
|
684
|
+
# Frozen two ways: the poller kept its last fleet strip (`/status` down), or the poller itself stopped and the
|
|
685
|
+
# whole document is older than ECONOMY_STALE by our clock (DEF-1447). Both are painted alike.
|
|
686
|
+
stale = fleet.get('stale') is True or self.economy_aged(now)
|
|
687
|
+
spend, budget = number(fleet.get('overflow_spend_today_usd')), number(fleet.get('overflow_budget_usd'))
|
|
688
|
+
fraction = number(fleet.get('spent_fraction'))
|
|
689
|
+
if fraction is None and spend is not None and budget:
|
|
690
|
+
fraction = spend / budget
|
|
691
|
+
alert = number(fleet.get('alert_level_pct'))
|
|
692
|
+
parts = [] # (text, role) in priority order
|
|
693
|
+
if spend is not None:
|
|
694
|
+
text = '$' + compact_usd(spend) + (f'/{compact_usd(budget)}' if budget is not None else '')
|
|
695
|
+
text += f' {round(fraction * 100)}%' if fraction is not None else ''
|
|
696
|
+
parts.append((text, 'tone-attention' if alert is not None and fraction is not None
|
|
697
|
+
and fraction * 100 >= alert else 'ink-dim'))
|
|
698
|
+
new_429 = number(fleet.get('upstream_429_delta'))
|
|
699
|
+
if new_429 is not None and new_429 > 0: # the poller reports growth only (None after a daemon restart)
|
|
700
|
+
parts.append((f'429 +{new_429}', 'tone-attention'))
|
|
701
|
+
has_fleet_fact = bool(parts)
|
|
702
|
+
spill, phase = number(fleet.get('spill_fraction')), fleet.get('pressure_phase')
|
|
703
|
+
free, accounts = number(fleet.get('effective_free')), number(fleet.get('accounts'))
|
|
704
|
+
reset = number(fleet.get('soonest_reset_5h'))
|
|
705
|
+
later = []
|
|
706
|
+
if spill is not None:
|
|
707
|
+
later.append((f'spill {round(spill * 100)}%',
|
|
708
|
+
'tone-attention' if isinstance(phase, str) and phase not in SPILL_QUIET else 'ink-dim'))
|
|
709
|
+
if free is not None:
|
|
710
|
+
later.append((f'free {free}' + (f'/{accounts}' if accounts is not None else ''), 'ink-dim'))
|
|
711
|
+
if reset is not None and reset > now:
|
|
712
|
+
later.append((f'reset {age3(reset - now).strip()}', 'ink-dim'))
|
|
713
|
+
if not (has_fleet_fact or later):
|
|
714
|
+
return '', ''
|
|
715
|
+
if self.economy_tiers:
|
|
716
|
+
parts.append((self.economy_tiers, 'ink-dim'))
|
|
717
|
+
parts += later
|
|
718
|
+
glyphs, tone = self.glyphs, self.tone
|
|
719
|
+
sep_plain = f' {glyphs["marks"]["sep"]} '
|
|
720
|
+
lead = f' {glyphs["marks"]["stale"]} ' if stale else ' '
|
|
721
|
+
painted, plain, used = [], [], cell_width(lead)
|
|
722
|
+
for text, role in parts:
|
|
723
|
+
cost = cell_width(text) + (cell_width(sep_plain) if plain else 0)
|
|
724
|
+
if used + cost > width:
|
|
725
|
+
break
|
|
726
|
+
role = 'ink-dim' if stale else role
|
|
727
|
+
painted.append(f'{tone[role]}{text}{tone["reset"]}')
|
|
728
|
+
plain.append(text)
|
|
729
|
+
used += cost
|
|
730
|
+
if not plain:
|
|
731
|
+
return '', ''
|
|
732
|
+
sep_painted = f'{tone["ink-dim"]}{sep_plain}{tone["reset"]}'
|
|
733
|
+
lead_painted = f'{tone["ink-dim"]}{lead}{tone["reset"]}' if stale else lead
|
|
734
|
+
return lead_painted + sep_painted.join(painted), lead + sep_plain.join(plain)
|
|
735
|
+
|
|
736
|
+
def attention(self, entry):
|
|
737
|
+
"""The attention section key of a registry entry, or None (the remainder)."""
|
|
738
|
+
return self.agent_rows([entry])[0][3]
|
|
739
|
+
|
|
740
|
+
def counts(self):
|
|
741
|
+
"""{needs, working, agents, shells} over this workspace's registry lanes: `needs` and `working` are the
|
|
742
|
+
sizes of those sections, `agents` the lanes running a harness (any status but `shell`), `shells` the rest."""
|
|
743
|
+
needs = working = agents = 0
|
|
744
|
+
for _entry, status, _since, key in self.agent_rows(self.entries):
|
|
745
|
+
needs += key == 'needs'
|
|
746
|
+
working += key == 'working'
|
|
747
|
+
agents += status is not None and status != 'shell'
|
|
748
|
+
return {'needs': needs, 'working': working, 'agents': agents, 'shells': len(self.entries) - agents}
|
|
749
|
+
|
|
750
|
+
def header_tally(self):
|
|
751
|
+
"""[(agent status, 'glyph+count')] for the header, in `HEADER_COUNTS` order, zeros omitted; a seen `done`
|
|
752
|
+
is not counted (it left the Done section)."""
|
|
753
|
+
counts = {}
|
|
754
|
+
for _entry, status, _since, key in self.agent_rows(self.entries):
|
|
755
|
+
if status in HEADER_COUNTS and (status != 'done' or key):
|
|
756
|
+
counts[status] = counts.get(status, 0) + 1
|
|
757
|
+
agent = self.glyphs['agent']
|
|
758
|
+
return [(k, f'{agent[k]}{counts[k]}') for k in HEADER_COUNTS if counts.get(k)]
|
|
759
|
+
|
|
760
|
+
def header_counts(self):
|
|
761
|
+
"""The header's right side, plain: `◆1 ◇2 ▸6 ✓1`, zeros omitted, '' when no lane has one (spec §4.3)."""
|
|
762
|
+
return ' '.join(text for _status, text in self.header_tally())
|
|
763
|
+
|
|
764
|
+
# ------------------------------------------------------------------ rows
|
|
765
|
+
def tail_for(self, observed, econ, now=None):
|
|
766
|
+
"""(glyph, role) for column 34, by priority (spec §4.2): not-observed beats everything, then what is
|
|
767
|
+
actionable — stale `?` > starving ⚠ > downgraded ↓ > paid `$` > a blank. `econ` is the lane's economy.json
|
|
768
|
+
object from `self.economy` or None (no poller, or a lane the poller has not joined: the tail is blank).
|
|
769
|
+
A FROZEN economy figure keeps its glyph but loses the attention tone (ink-dim, DEF-1447): the lane record
|
|
770
|
+
carries `stale: true` (the poller kept it while `/sessions` was down), or — with `now` — the whole document
|
|
771
|
+
is older than ECONOMY_STALE. A frozen ⚠ is last-known, not a live alarm; the `?` stays statusd's mark."""
|
|
772
|
+
if observed.get('stale'):
|
|
773
|
+
return self.glyphs['marks']['stale'], 'ink-dim'
|
|
774
|
+
if not econ:
|
|
775
|
+
return ' ', 'ink-dim'
|
|
776
|
+
if starving(observed, econ):
|
|
777
|
+
kind = 'starving'
|
|
778
|
+
elif (number(econ.get('downgraded_5m')) or 0) > 0:
|
|
779
|
+
kind = 'downgraded'
|
|
780
|
+
elif (number(econ.get('paid_share')) or 0) > 0.5:
|
|
781
|
+
kind = 'paid'
|
|
782
|
+
else:
|
|
783
|
+
return ' ', 'ink-dim'
|
|
784
|
+
frozen = econ.get('stale') is True or (now is not None and self.economy_aged(now))
|
|
785
|
+
return self.glyphs['economy'][kind], 'ink-dim' if frozen else ECONOMY_TAIL[kind][2]
|
|
786
|
+
|
|
787
|
+
def row(self, entry, index, target, others, cols, now, section_tone=None):
|
|
788
|
+
"""One lane row (spec §4.2): columns 1-35 written, column 36 never (the caller's `\\033[K` clears it).
|
|
789
|
+
cursor 1 · agent 2 · mark 3 · name 4-27 · space · age 29-31 · space · stream 33 · tail 34 · × 35.
|
|
790
|
+
Cached per lane by everything that can change its bytes (spec §4.11); `row_hits`/`row_misses` count it."""
|
|
791
|
+
observed = self.status.get(entry['id']) or {}
|
|
792
|
+
agent = observed.get('agent') if isinstance(observed.get('agent'), dict) else {}
|
|
793
|
+
status = agent.get('status') if agent.get('status') in AGENT_GLYPHS else None
|
|
794
|
+
econ = (self.economy.get('lanes') or {}).get(entry['id']) if self.economy else None
|
|
795
|
+
state = observed.get('state', 'helper' if entry.get('holder') == 'exec' else 'unknown')
|
|
796
|
+
key_state = state if state in GLYPHS else 'unknown'
|
|
797
|
+
stale = bool(observed.get('stale'))
|
|
798
|
+
since = agent.get('since') if status else observed.get('activity')
|
|
799
|
+
age = age3(int(now - since) if isinstance(since, (int, float)) and since > 0 else None)
|
|
800
|
+
is_cursor, is_viewing = index == self.cursor, entry['id'] == target
|
|
801
|
+
mark = ' '
|
|
802
|
+
for slot, name in zip(SLOTS[1:self.viewports], ('viewport-b', 'viewport-c')):
|
|
803
|
+
if others.get(slot) == entry['id']:
|
|
804
|
+
mark = self.glyphs['marks'][name]
|
|
805
|
+
if mark == ' ' and is_viewing and not is_cursor:
|
|
806
|
+
mark = self.glyphs['marks']['viewing']
|
|
807
|
+
# A lane statusd never observed already shows `?` as its stream glyph: one `?`, not two.
|
|
808
|
+
tail, tail_role = self.tail_for(observed if key_state != 'unknown' else {}, econ, now)
|
|
809
|
+
viewers = viewer_mark(observed).replace('·', self.glyphs['marks']['viewer'])
|
|
810
|
+
closable = bool(entry.get('closable'))
|
|
811
|
+
can_register = closable and R.eligible(entry)
|
|
812
|
+
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)
|
|
814
|
+
cached = self.row_cache.get(entry['id'])
|
|
815
|
+
if cached and cached[0] == key:
|
|
816
|
+
self.row_hits += 1
|
|
817
|
+
return cached[1]
|
|
818
|
+
self.row_misses += 1
|
|
819
|
+
tone, g, reset = self.tone, self.glyphs, self.tone['reset']
|
|
820
|
+
icon = ''
|
|
821
|
+
name_width = cols - 12 - len(viewers)
|
|
822
|
+
if self.glyph_mode == 'emoji': # spec §3: the LANE_ICONS column, 3 cells after column 3, taken from the name
|
|
823
|
+
icon = entry.get('icon') or L.LANE_ICONS.get('terminal', '💻')
|
|
824
|
+
icon += ' ' * max(0, 3 - cell_width(icon))
|
|
825
|
+
name_width -= 3
|
|
826
|
+
name_width = max(1, name_width)
|
|
827
|
+
name = entry.get('name', entry['id'])
|
|
828
|
+
clipped, used = clip(name, name_width)
|
|
829
|
+
if len(clipped) < len(name): # truncated: one cell less for the name, then `…` (no second width walk)
|
|
830
|
+
while clipped and used > name_width - 1:
|
|
831
|
+
used -= cell_width(clipped[-1])
|
|
832
|
+
clipped = clipped[:-1]
|
|
833
|
+
clipped, used = clipped + g['marks']['truncation'], used + 1
|
|
834
|
+
pad = ' ' * max(0, name_width - used)
|
|
835
|
+
cursor = f'{tone["accent"]}{g["marks"]["cursor"]}{reset}' if is_cursor else ' '
|
|
836
|
+
agent_cell = ' '
|
|
837
|
+
if status:
|
|
838
|
+
agent_role = 'ink-dim' if stale else tokens.tone_of(AGENT_GLYPHS, status)
|
|
839
|
+
agent_cell = f'{tone[agent_role]}{g["agent"][status]}{reset}'
|
|
840
|
+
mark_cell = f'{tone["accent"]}{mark}{reset}' if mark != ' ' else ' '
|
|
841
|
+
color = entry.get('color')
|
|
842
|
+
# The lane's identity colour (38;5;N) is a colour: under NO_COLOR (`none`) the name is plain ink.
|
|
843
|
+
fg = f'\033[38;5;{color}m' if isinstance(color, int) and self.color != 'none' else ''
|
|
844
|
+
lead = (tone['bold'] if is_viewing else '') + fg
|
|
845
|
+
name_cell = f'{lead}{clipped}{reset}' if lead else clipped
|
|
846
|
+
if viewers:
|
|
847
|
+
name_cell += f'{tone["ink-dim"]}{viewers}{reset}'
|
|
848
|
+
# The `+` register control (README "Daily use") has no column in spec §4.2: it takes the spacer cell 28
|
|
849
|
+
# (= cols − 8), inside its click box `cols − 8 ≤ x < cols − 4`, so the age keeps its place. It and the `×`
|
|
850
|
+
# are POSITIONED (CHA): a name whose terminal width disagrees with `cell_width` cannot push either control
|
|
851
|
+
# off its hit box.
|
|
852
|
+
spacer = f'\033[{max(1, cols - 8)}G{tone["tone-done"]}{g["marks"]["new"]}{reset}' if can_register else ' '
|
|
853
|
+
stream_role = 'ink-dim' if (stale or status) else tokens.tone_of(GLYPHS, key_state)
|
|
854
|
+
stream = f'{tone[stream_role]}{g["stream"][key_state]}{reset}'
|
|
855
|
+
tail_cell = f'{tone[tail_role]}{tail}{reset}' if tail != ' ' else ' '
|
|
856
|
+
close = f'\033[{max(1, cols - 1)}G{tone["tone-urgent"]}{g["marks"]["close"]}{reset}' if closable else ' '
|
|
857
|
+
age_role = section_tone or 'ink-dim'
|
|
858
|
+
text = (f'{cursor}{agent_cell}{mark_cell}{icon}{name_cell}{pad}{spacer}{tone[age_role]}{age}{reset} '
|
|
859
|
+
f'{stream}{tail_cell}{close}')
|
|
860
|
+
self.row_cache[entry['id']] = (key, text)
|
|
861
|
+
return text
|
|
862
|
+
|
|
863
|
+
def sectioned(self, lanes):
|
|
864
|
+
"""Registry lanes in attention order (spec §4.4): Needs you (by urgency, oldest first), Working (oldest
|
|
865
|
+
first), Done (newest first, unseen only), then the rest in registry order under a workspace header — drawn
|
|
866
|
+
only when an attention section exists, so a workspace without agents looks exactly as before."""
|
|
867
|
+
buckets, rest = {key: [] for key, *_ in ATTENTION}, []
|
|
868
|
+
for entry, status, since, key in self.agent_rows(lanes):
|
|
869
|
+
if key is None:
|
|
870
|
+
rest.append(entry)
|
|
871
|
+
elif key == 'needs': # urgency, then the oldest `since` first; an unknown `since` sorts last
|
|
872
|
+
buckets[key].append(((NEEDS_RANK[status], INF if since is None else since), status, entry))
|
|
873
|
+
elif key == 'working':
|
|
874
|
+
buckets[key].append((INF if since is None else since, status, entry))
|
|
875
|
+
else: # done: newest first
|
|
876
|
+
buckets[key].append((INF if since is None else -since, status, entry))
|
|
877
|
+
if not any(buckets.values()):
|
|
878
|
+
return rest
|
|
879
|
+
out = []
|
|
880
|
+
for key, title, _glyph, _members in ATTENTION:
|
|
881
|
+
if buckets[key]:
|
|
882
|
+
bucket = sorted(buckets[key], key=itemgetter(0))
|
|
883
|
+
# `status`: the most urgent member's, whose glyph heads the section (`── ◆ Needs you (3)`, spec §4.4)
|
|
884
|
+
out.append({'id': '', 'divider': True, 'name': f'{title} ({len(bucket)})', 'section': key,
|
|
885
|
+
'status': bucket[0][1]})
|
|
886
|
+
out += [entry for _rank, _status, entry in bucket]
|
|
887
|
+
if rest:
|
|
888
|
+
out.append({'id': '', 'divider': True, 'name': f'{self.title} ({len(rest)})', 'section': 'rest'})
|
|
889
|
+
return out + rest
|
|
890
|
+
|
|
241
891
|
def visible(self):
|
|
242
|
-
entries =
|
|
892
|
+
self.last_visible = entries = self._visible()
|
|
893
|
+
return entries
|
|
894
|
+
|
|
895
|
+
# ---------------------------------------------------------------- cursor
|
|
896
|
+
@property
|
|
897
|
+
def cursor_id(self):
|
|
898
|
+
"""The lane the cursor is on: the id of the entry `self.cursor` indexes in `last_visible` (the list it was set
|
|
899
|
+
against), None on a divider or past the end. Derived, never stored, so it cannot drift from the index."""
|
|
900
|
+
entries = self.last_visible
|
|
901
|
+
if 0 <= self.cursor < len(entries) and not entries[self.cursor].get('divider'):
|
|
902
|
+
return entries[self.cursor].get('id') or None
|
|
903
|
+
return None
|
|
904
|
+
|
|
905
|
+
def resolve_cursor(self, entries, cursor_id):
|
|
906
|
+
"""The cursor is a LANE (spec §4.5). `entries` is a freshly computed list, `cursor_id` the lane the cursor was
|
|
907
|
+
on before it: the index is re-found from the id, and `top` shifts by the rows that appeared above it, so a lane
|
|
908
|
+
that moves between sections never moves the operator's eye. Then the index is clamped. render() and
|
|
909
|
+
render_mobile() bound the shifted `top` by the list (DEF-1433): a list that fits the body is never scrolled."""
|
|
910
|
+
if cursor_id:
|
|
911
|
+
index = next((i for i, e in enumerate(entries) if e.get('id') == cursor_id), None)
|
|
912
|
+
if index is not None and index != self.cursor:
|
|
913
|
+
self.top = max(0, self.top + (index - self.cursor))
|
|
914
|
+
self.cursor = index
|
|
915
|
+
self.cursor = max(0, min(self.cursor, len(entries) - 1))
|
|
916
|
+
|
|
917
|
+
def follow(self):
|
|
918
|
+
"""The current list with the cursor re-resolved onto its lane: what render() and every key in handle() use.
|
|
919
|
+
The list can change without a reload (opening a `done` lane marks it seen; a close, a new shell)."""
|
|
920
|
+
cursor_id = self.cursor_id
|
|
921
|
+
entries = self.visible()
|
|
922
|
+
self.resolve_cursor(entries, cursor_id)
|
|
923
|
+
return entries
|
|
924
|
+
|
|
925
|
+
def set_cursor(self, index):
|
|
926
|
+
"""Put the cursor on `index` of a list computed NOW (a filter keystroke, the registration picker): the list it
|
|
927
|
+
indexes is refreshed with it, so the next follow() does not drag it back to the lane it was on before."""
|
|
928
|
+
entries = self.visible()
|
|
929
|
+
self.cursor = max(0, min(index, len(entries) - 1))
|
|
930
|
+
|
|
931
|
+
def needs_you(self):
|
|
932
|
+
"""The visible lanes whose agent needs you, in section order (approval, errored, input; oldest first)."""
|
|
933
|
+
out = []
|
|
934
|
+
for entry in self.visible():
|
|
935
|
+
agent = None if entry.get('divider') else (self.status.get(entry.get('id', '')) or {}).get('agent')
|
|
936
|
+
if isinstance(agent, dict) and agent.get('status') in NEEDS_YOU:
|
|
937
|
+
out.append(entry)
|
|
938
|
+
return out
|
|
939
|
+
|
|
940
|
+
def jump_needs_you(self, step):
|
|
941
|
+
"""Tab / Shift-Tab: open the next / previous lane that needs you, cycling. Relative to the lane this view SHOWS
|
|
942
|
+
(the target file); relative to the cursor lane when the shown one is not in the list (nothing shown yet, or it
|
|
943
|
+
does not need you); from the first / last only when neither is — so two Tabs always visit two lanes, also
|
|
944
|
+
before open_lane has moved the target (spec §4.5)."""
|
|
945
|
+
lanes = self.needs_you()
|
|
946
|
+
if not lanes:
|
|
947
|
+
self.message = 'nothing needs you'
|
|
948
|
+
return
|
|
949
|
+
ids = [e['id'] for e in lanes]
|
|
950
|
+
target, cursor_id = self.current_target(), self.cursor_id
|
|
951
|
+
if target in ids:
|
|
952
|
+
index = ids.index(target)
|
|
953
|
+
elif cursor_id in ids:
|
|
954
|
+
index = ids.index(cursor_id)
|
|
955
|
+
else:
|
|
956
|
+
index = -1 if step > 0 else 0
|
|
957
|
+
entry = lanes[(index + step) % len(lanes)]
|
|
958
|
+
self.cursor = self.last_visible.index(entry) # needs_you() just computed last_visible
|
|
959
|
+
self.open_lane(entry)
|
|
960
|
+
|
|
961
|
+
def osc_bytes(self, target):
|
|
962
|
+
"""The pane title when the header counts changed since the last frame, and one OSC 9 per lane per transition
|
|
963
|
+
into needs-you — never for `target` (the lane this view shows), never on a phone, never under LANES_NOTIFY=0
|
|
964
|
+
(spec §4.9). Strings only: they ride the frame's single write, after the footer."""
|
|
965
|
+
out = ''
|
|
966
|
+
counts = self.header_counts()
|
|
967
|
+
if counts != self.last_counts:
|
|
968
|
+
self.last_counts = counts
|
|
969
|
+
out += f'\033]2;{osc_text(self.title + (" · " + counts if counts else ""))}\033\\'
|
|
970
|
+
quiet = self.mobile or os.environ.get('LANES_NOTIFY') == '0'
|
|
971
|
+
notified, now = self.notified, {}
|
|
972
|
+
for entry in self.entries:
|
|
973
|
+
agent = (self.status.get(entry.get('id', '')) or {}).get('agent')
|
|
974
|
+
status = agent.get('status') if isinstance(agent, dict) else None
|
|
975
|
+
if status not in NEEDS_YOU:
|
|
976
|
+
continue
|
|
977
|
+
now[entry['id']] = status
|
|
978
|
+
if not quiet and notified.get(entry['id']) != status and entry['id'] != target:
|
|
979
|
+
text = osc_text(f'{entry.get("name", entry["id"])}: {agent.get("name") or status}')
|
|
980
|
+
out += f'\033]9;{text}\033\\'
|
|
981
|
+
self.notified = now # a lane that left needs-you is forgotten: its next entry is news again
|
|
982
|
+
return out
|
|
983
|
+
|
|
984
|
+
def _visible(self):
|
|
985
|
+
if self.registration:
|
|
986
|
+
return self.registration_choices + [{'id': '', 'name': 'Cancel', 'action': 'cancel-registration'}]
|
|
987
|
+
# Filter mode flattens the sections exactly as before (dividers are dropped below); the agent glyph stays.
|
|
988
|
+
entries = list(self.entries) if self.filter else self.sectioned(self.entries)
|
|
243
989
|
if self.zmx:
|
|
244
990
|
entries.append({'id': '', 'divider': True, 'name': f'zmx lanes ({len(self.zmx)})'})
|
|
245
991
|
entries += self.zmx
|
|
246
992
|
# Everything statusd lists as unregistered is shown, `view-…` names included: under the viewer rule
|
|
247
|
-
# those are orphaned or operator-made sessions that are ALIVE, and nothing alive is ever hidden
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
993
|
+
# those are orphaned or operator-made sessions that are ALIVE, and nothing alive is ever hidden —
|
|
994
|
+
# except a session that IS registered (it is listed under its workspace instead, never twice).
|
|
995
|
+
registered = {R.key(lane) for w in self.workspaces for lane in w.get('lanes', []) if lane.get('holder') == 'tmux'}
|
|
996
|
+
unregistered = [e for e in self.unregistered if not R.eligible(e) or R.key(e) not in registered]
|
|
997
|
+
if not self.attach_only:
|
|
998
|
+
entries.append({'id': '', 'divider': True, 'name': f'Unregistered ({len(unregistered)})'})
|
|
999
|
+
entries.append({'id': self.workspace + '--new-shell', 'name': 'New +', 'action': 'new-shell', 'icon': '+'})
|
|
1000
|
+
entries += unregistered
|
|
251
1001
|
if not self.filter:
|
|
252
1002
|
return entries
|
|
253
1003
|
needle = self.filter.lower()
|
|
@@ -279,6 +1029,12 @@ class Sidebar:
|
|
|
279
1029
|
def open_lane(self, entry, slot=None):
|
|
280
1030
|
if entry.get('divider'):
|
|
281
1031
|
return
|
|
1032
|
+
if entry.get('action') == 'cancel-registration':
|
|
1033
|
+
self.cancel_registration()
|
|
1034
|
+
return
|
|
1035
|
+
if entry.get('action') == 'register-workspace':
|
|
1036
|
+
self.register_lane(entry['workspace'])
|
|
1037
|
+
return
|
|
282
1038
|
slot = slot or self.slot
|
|
283
1039
|
if slot not in SLOTS[:self.viewports]:
|
|
284
1040
|
self.message = 'this view has no such viewport (open a wide view: lanes-window w2)'
|
|
@@ -287,23 +1043,14 @@ class Sidebar:
|
|
|
287
1043
|
if entry.get('action') == 'new-shell':
|
|
288
1044
|
self.new_shell(slot)
|
|
289
1045
|
return
|
|
290
|
-
|
|
1046
|
+
if self.agent_of(entry['id']):
|
|
1047
|
+
self.mark_seen(entry['id']) # the one caller (spec §10): opening a lane is looking at it
|
|
291
1048
|
if self.slot_target(slot) == entry['id']:
|
|
292
1049
|
self.focus_lane(slot)
|
|
293
1050
|
self.filter, self.filtering, self.dirty = '', False, True
|
|
294
1051
|
return
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
with open(tmp, 'w') as handle:
|
|
298
|
-
handle.write(entry['id'] + '\n')
|
|
299
|
-
os.replace(tmp, target_file)
|
|
300
|
-
try:
|
|
301
|
-
with open(pid_file) as handle:
|
|
302
|
-
pid = int(handle.read().strip())
|
|
303
|
-
with open(f'/proc/{pid}/cmdline', 'rb') as handle:
|
|
304
|
-
if b'view.sh' in handle.read():
|
|
305
|
-
os.kill(pid, signal.SIGUSR1)
|
|
306
|
-
except (OSError, ValueError):
|
|
1052
|
+
# The one "write the target, wake that view.sh" (lanes_lib.show_lane), shared with `lanes next|prev`.
|
|
1053
|
+
if not L.show_lane(self.view_dir, self.workspace, slot, entry['id']):
|
|
307
1054
|
self.message = 'view not running yet'
|
|
308
1055
|
self.focus_lane(slot)
|
|
309
1056
|
self.filter, self.filtering = '', False
|
|
@@ -322,6 +1069,11 @@ class Sidebar:
|
|
|
322
1069
|
self.message = str(error)
|
|
323
1070
|
self.dirty = True
|
|
324
1071
|
|
|
1072
|
+
def request_close(self, entry):
|
|
1073
|
+
"""`x` or a click on `×`: ask first (spec §4.6(a)); handle() closes on `y` and cancels on anything else."""
|
|
1074
|
+
if entry.get('closable'):
|
|
1075
|
+
self.confirming, self.dirty = entry, True
|
|
1076
|
+
|
|
325
1077
|
def close_lane(self, entry):
|
|
326
1078
|
if not entry.get('closable'):
|
|
327
1079
|
return
|
|
@@ -354,21 +1106,26 @@ class Sidebar:
|
|
|
354
1106
|
self.dirty = True
|
|
355
1107
|
return
|
|
356
1108
|
try:
|
|
357
|
-
|
|
1109
|
+
# Routed by the registry SHAPE, never by the id's spelling: an adopted session keeps its `--x-` id
|
|
1110
|
+
# but its name authority is registered-sessions.json, not user-sessions/ (DEF-1475).
|
|
1111
|
+
if entry.get('registered_session'):
|
|
1112
|
+
R.rename(entry['id'], new_name)
|
|
1113
|
+
self.message = f'Renamed → {new_name}'
|
|
1114
|
+
self.reload(force=True)
|
|
1115
|
+
elif entry.get('closable'):
|
|
358
1116
|
# Unregistered session
|
|
359
|
-
|
|
360
|
-
for
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
lst[i] = dict(e, name=new_name)
|
|
1117
|
+
A.rename_session(self.workspace, entry, new_name)
|
|
1118
|
+
for i, e in enumerate(self.unregistered):
|
|
1119
|
+
if e['id'] == entry['id']:
|
|
1120
|
+
self.unregistered[i] = dict(e, name=new_name)
|
|
364
1121
|
self.message = f'Renamed → {new_name}'
|
|
365
1122
|
else:
|
|
366
|
-
# Registered lane:
|
|
367
|
-
|
|
1123
|
+
# Registered lane: a repositories[i].displayNames override in the board manifest + sync.
|
|
1124
|
+
# The lane id never changes, so the cursor, view targets and `lane open <id>` keep working.
|
|
368
1125
|
registry_name = entry.get('registry_name') or entry.get('name', '')
|
|
369
|
-
if not
|
|
370
|
-
raise L.LanesError('No
|
|
371
|
-
A.rename_registered(
|
|
1126
|
+
if not entry.get('repo'):
|
|
1127
|
+
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)
|
|
372
1129
|
# Trigger a registry rebuild
|
|
373
1130
|
import subprocess as _sp
|
|
374
1131
|
_sp.Popen([os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
@@ -383,8 +1140,15 @@ class Sidebar:
|
|
|
383
1140
|
"""Begin promotion confirmation for an unregistered session."""
|
|
384
1141
|
if entry.get('divider') or entry.get('action'):
|
|
385
1142
|
return
|
|
386
|
-
|
|
387
|
-
|
|
1143
|
+
# Gated the way `start_registration` is: only an UNREGISTERED, reconnectable tmux session. A repository
|
|
1144
|
+
# lane or an adopted (`registered_session`) lane is already registered; promoting it would append a
|
|
1145
|
+
# second declaration of the same binding to the repository's terminals.json (DEF-1474).
|
|
1146
|
+
if entry.get('registered_session') or entry.get('repo') or entry.get('source'):
|
|
1147
|
+
self.message = 'Already a registered lane; nothing to promote'
|
|
1148
|
+
self.dirty = True
|
|
1149
|
+
return
|
|
1150
|
+
if not entry.get('closable') or not R.eligible(entry):
|
|
1151
|
+
self.message = 'Only unregistered tmux sessions can be promoted'
|
|
388
1152
|
self.dirty = True
|
|
389
1153
|
return
|
|
390
1154
|
self.promoting = True
|
|
@@ -413,6 +1177,42 @@ class Sidebar:
|
|
|
413
1177
|
self.message = str(error)
|
|
414
1178
|
self.dirty = True
|
|
415
1179
|
|
|
1180
|
+
def start_registration(self, entry):
|
|
1181
|
+
if not entry.get('closable') or not R.eligible(entry):
|
|
1182
|
+
return
|
|
1183
|
+
choices = R.destinations({'workspaces': self.workspaces}, entry)
|
|
1184
|
+
choices.sort(key=lambda w: w['id'] != self.workspace)
|
|
1185
|
+
if not choices:
|
|
1186
|
+
self.message, self.dirty = 'No workspace uses this session’s container', True
|
|
1187
|
+
return
|
|
1188
|
+
self.registration = entry
|
|
1189
|
+
self.message = ''
|
|
1190
|
+
self.armed = False
|
|
1191
|
+
self.registration_choices = [{'id': w['id'], 'workspace': w['id'], 'name': w.get('title') or w['id'],
|
|
1192
|
+
'action': 'register-workspace'} for w in choices]
|
|
1193
|
+
self.filter, self.filtering, self.top, self.dirty = '', False, 0, True
|
|
1194
|
+
self.set_cursor(0)
|
|
1195
|
+
|
|
1196
|
+
def cancel_registration(self):
|
|
1197
|
+
self.registration, self.registration_choices = None, []
|
|
1198
|
+
self.top, self.dirty = 0, True
|
|
1199
|
+
self.set_cursor(0)
|
|
1200
|
+
|
|
1201
|
+
def register_lane(self, workspace):
|
|
1202
|
+
entry = self.registration
|
|
1203
|
+
if not entry:
|
|
1204
|
+
return
|
|
1205
|
+
try:
|
|
1206
|
+
lane = R.register(self.workspace, entry, workspace)
|
|
1207
|
+
self.unregistered = [e for e in self.unregistered if e['id'] != lane['id']]
|
|
1208
|
+
title = next((w.get('title') or w['id'] for w in self.workspaces if w['id'] == workspace), workspace)
|
|
1209
|
+
self.cancel_registration()
|
|
1210
|
+
self.reload(force=True)
|
|
1211
|
+
self.message = 'Added to ' + title
|
|
1212
|
+
except (L.LanesError, OSError, ValueError) as error:
|
|
1213
|
+
self.message = str(error)
|
|
1214
|
+
self.dirty = True
|
|
1215
|
+
|
|
416
1216
|
def spools(self):
|
|
417
1217
|
"""Where an upload request may be spooled, best first. A suffixed view (mac-2, mac-w2) belongs to the machine
|
|
418
1218
|
whose base device (mac) `lanes attach` recorded in devices/<device>.json: that machine's ONE helper serves
|
|
@@ -438,12 +1238,13 @@ class Sidebar:
|
|
|
438
1238
|
token = re.sub(r'%([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), token[7:])
|
|
439
1239
|
if token.startswith('/') and '\n' not in token:
|
|
440
1240
|
paths.append(token)
|
|
1241
|
+
fail = self.glyphs['marks']['fail'] + ' '
|
|
441
1242
|
if not paths or len(paths) != len(tokens):
|
|
442
|
-
self.upload_note, self.upload_note_at = 'that was not a file drop', time.time()
|
|
1243
|
+
self.upload_note, self.upload_note_at = fail + 'that was not a file drop', time.time()
|
|
443
1244
|
return
|
|
444
1245
|
spool = next((s for s in self.spools() if time.time() - mtime(os.path.join(s, 'agent.alive')) / 1e9 <= 12), None)
|
|
445
1246
|
if not spool: # no helper is serving either spool
|
|
446
|
-
self.upload_note, self.upload_note_at = 'no upload helper on this device (reopen lanes-launch)', time.time()
|
|
1247
|
+
self.upload_note, self.upload_note_at = fail + 'no upload helper on this device (reopen lanes-launch)', time.time()
|
|
447
1248
|
return
|
|
448
1249
|
request = f'{int(time.time() * 1000)}-{os.getpid()}'
|
|
449
1250
|
# The sidecar names the view that asked — the device whose lane gets the paste (`lanes upload --request`).
|
|
@@ -457,6 +1258,8 @@ class Sidebar:
|
|
|
457
1258
|
self.upload = {'id': request, 'spool': spool, 'names': [os.path.basename(p) for p in paths], 'at': time.time()}
|
|
458
1259
|
|
|
459
1260
|
def poll_upload(self):
|
|
1261
|
+
"""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."""
|
|
460
1263
|
if not self.upload:
|
|
461
1264
|
return
|
|
462
1265
|
base = os.path.join(self.upload['spool'], self.upload['id']) # the results land where the request went
|
|
@@ -464,18 +1267,20 @@ class Sidebar:
|
|
|
464
1267
|
if progress_at > self.upload.get('progress_at', 0):
|
|
465
1268
|
progress = read_json(base + '.progress') or {}
|
|
466
1269
|
self.upload.update(progress_at=progress_at, bytes=progress.get('bytes', 0))
|
|
1270
|
+
self.dirty = True
|
|
1271
|
+
ok, fail = self.glyphs['marks']['ok'] + ' ', self.glyphs['marks']['fail'] + ' '
|
|
467
1272
|
note = None
|
|
468
1273
|
if os.path.exists(base + '.done'):
|
|
469
|
-
note =
|
|
1274
|
+
note = ok + ', '.join(self.upload['names'])[:60]
|
|
470
1275
|
self.focus_lane()
|
|
471
1276
|
elif os.path.exists(base + '.err'):
|
|
472
1277
|
try:
|
|
473
1278
|
with open(base + '.err') as handle:
|
|
474
|
-
note =
|
|
1279
|
+
note = fail + handle.readline().strip()[:70]
|
|
475
1280
|
except OSError:
|
|
476
|
-
note = '
|
|
1281
|
+
note = fail + 'upload failed'
|
|
477
1282
|
elif time.time() - max(self.upload['at'], progress_at) > 120:
|
|
478
|
-
note = '
|
|
1283
|
+
note = fail + 'upload timed out'
|
|
479
1284
|
if note:
|
|
480
1285
|
for suffix in ('.done', '.err', '.sent', '.req', '.dev', '.progress'):
|
|
481
1286
|
try:
|
|
@@ -495,26 +1300,70 @@ class Sidebar:
|
|
|
495
1300
|
except (OSError, subprocess.SubprocessError):
|
|
496
1301
|
break
|
|
497
1302
|
|
|
498
|
-
def
|
|
499
|
-
"""The upload
|
|
500
|
-
|
|
1303
|
+
def strip_line(self, cols):
|
|
1304
|
+
"""The upload strip (spec §4.6(d)): ONE row under the body, at most `cols − 1` cells — idle, armed, sending,
|
|
1305
|
+
or the result for 8 s. Every glyph and separator is a mark, so the ASCII frame is seven-bit."""
|
|
1306
|
+
tone, g = self.tone, self.glyphs['marks']
|
|
1307
|
+
sep = f' {g["sep"]} '
|
|
501
1308
|
if self.upload:
|
|
502
|
-
|
|
503
|
-
|
|
1309
|
+
count = len(self.upload['names'])
|
|
1310
|
+
text, style = f' {g["upload"]} sending {count} file{"s" if count != 1 else ""}', tone['tone-working']
|
|
504
1311
|
if self.upload.get('bytes'):
|
|
505
|
-
|
|
1312
|
+
text += f'{sep}{self.upload["bytes"] / 1048576:.1f} MiB'
|
|
506
1313
|
elif self.armed:
|
|
507
|
-
|
|
1314
|
+
text, style = f' {g["armed"]} DROP FILES NOW{sep}anywhere{sep}Esc', tone['bold'] + tone['tone-done']
|
|
508
1315
|
elif self.upload_note and time.time() - self.upload_note_at < 8:
|
|
509
|
-
|
|
1316
|
+
text = ' ' + self.upload_note
|
|
1317
|
+
style = tone['tone-done'] if self.upload_note.startswith(g['ok']) else tone['tone-urgent']
|
|
510
1318
|
else:
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
1319
|
+
text, style = f' {g["upload"]} uploads{sep}click, then drop (U)', tone['ink-dim']
|
|
1320
|
+
text, _used = clip(text, cols - 1)
|
|
1321
|
+
return f'{style}{text}{tone["reset"]}'
|
|
1322
|
+
|
|
1323
|
+
def divider_line(self, entry, cols):
|
|
1324
|
+
"""`── ◆ Needs you (3) ───` (spec §4.4), shared by the desktop and the phone (§4.10 "section headers as
|
|
1325
|
+
desktop"): the rule ink-faint, the words ink-dim, the glyph of the section's most urgent member in its tone,
|
|
1326
|
+
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`."""
|
|
1328
|
+
tone, marks, reset = self.tone, self.glyphs['marks'], self.tone['reset']
|
|
1329
|
+
attention = next((s for s in ATTENTION if s[0] == entry.get('section')), None)
|
|
1330
|
+
rule, dim, faint = marks['rule'], tone['ink-dim'], tone['ink-faint']
|
|
1331
|
+
status = (entry.get('status') if entry.get('status') in AGENT_GLYPHS else attention[2]) if attention else None
|
|
1332
|
+
glyph = f'{self.glyphs["agent"][status]} ' if status else ''
|
|
1333
|
+
label, used_cols = clip(f'{glyph}{entry["name"]}', max(1, cols - 5))
|
|
1334
|
+
if status and label.startswith(glyph):
|
|
1335
|
+
painted = f'{tone[AGENT_GLYPHS[status][2]]}{glyph[:-1]}{reset}{dim} {label[len(glyph):]} {reset}'
|
|
1336
|
+
else:
|
|
1337
|
+
painted = f'{dim}{label} {reset}'
|
|
1338
|
+
fill = rule * max(0, cols - 1 - 4 - used_cols)
|
|
1339
|
+
return f'{faint}{rule * 2} {reset}{painted}{faint}{fill}{reset}'
|
|
1340
|
+
|
|
1341
|
+
def mobile_counts(self):
|
|
1342
|
+
"""The phone header (spec §4.10): ` ◆1 need approval · ◇2 need you · ▸6 working`, from the same tally as the
|
|
1343
|
+
desktop header (zeros omitted, a seen `done` not counted), parts joined by the `sep` mark; '' when no lane
|
|
1344
|
+
has an agent status worth counting."""
|
|
1345
|
+
tally = self.header_tally()
|
|
1346
|
+
if not tally:
|
|
1347
|
+
return ''
|
|
1348
|
+
sep = f' {self.glyphs["marks"]["sep"]} '
|
|
1349
|
+
return ' ' + sep.join(f'{text} {MOBILE_COUNT_WORDS[status]}' for status, text in tally)
|
|
1350
|
+
|
|
1351
|
+
def key_sheet(self, cols, body):
|
|
1352
|
+
"""`body` lines: the key sheet `?` shows (spec §4.7), ` {key:<10}{action}`, the key ink-dim; padded with
|
|
1353
|
+
blank lines, cut to the body. The seven-bit twins under LANES_GLYPHS=ascii."""
|
|
1354
|
+
index = 1 if self.glyph_mode == 'ascii' else 0
|
|
1355
|
+
dim, reset = self.tone['ink-dim'], self.tone['reset']
|
|
1356
|
+
lines = []
|
|
1357
|
+
for row in KEY_SHEET:
|
|
1358
|
+
key = row[index]
|
|
1359
|
+
action, _ = clip(row[2], max(0, cols - 13))
|
|
1360
|
+
lines.append(f'{dim} {key:<10}{reset}{action}')
|
|
1361
|
+
return (lines + [''] * body)[:body]
|
|
1362
|
+
|
|
1363
|
+
@staticmethod
|
|
1364
|
+
def tick_timeout():
|
|
1365
|
+
"""run()'s select() timeout: 1 s, always. An upload no longer shortens it — its strip repaints on progress."""
|
|
1366
|
+
return 1.0
|
|
518
1367
|
|
|
519
1368
|
# ------------------------------------------------------------ rendering
|
|
520
1369
|
def render(self):
|
|
@@ -526,119 +1375,167 @@ class Sidebar:
|
|
|
526
1375
|
if self.mobile:
|
|
527
1376
|
self.render_mobile(cols, rows)
|
|
528
1377
|
return
|
|
529
|
-
entries = self.
|
|
530
|
-
self.
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
1378
|
+
entries = self.follow() # the cursor re-resolved onto its lane, clamped (spec §4.5)
|
|
1379
|
+
if self.theme_pending and time.monotonic() >= self.theme_deadline:
|
|
1380
|
+
self.apply_theme(self.variant) # no OSC 11 answer within the grace: dark it is (spec §2.4)
|
|
1381
|
+
now = time.time()
|
|
1382
|
+
# Frame order (spec §4.6): header, body, upload strip (rows ≥ 14), strip row (rows ≥ 18, economy or fleet
|
|
1383
|
+
# attention to show), rule, footer. `box_top` is the upload strip's 1-based row; a click at or below it arms.
|
|
1384
|
+
# The strip row is the fleet economy line when economy.json knows a fleet fact (Task 12), else fleet attention.
|
|
1385
|
+
self.box = rows >= 14 and not self.registration and not self.attach_only
|
|
1386
|
+
economy_line = self.economy_strip(cols - 2, now)[0] if self.economy and rows >= 18 else ''
|
|
1387
|
+
self.strip_row = rows >= 18 and (bool(economy_line) or bool(self.fleet))
|
|
1388
|
+
body = max(1, rows - 3 - (BOX_ROWS if self.box else 0) - (1 if self.strip_row else 0))
|
|
1389
|
+
self.box_top = 2 + body
|
|
1390
|
+
# resolve_cursor()'s eye-preserving shift is bounded by the list: a list that ends inside the body is never
|
|
1391
|
+
# scrolled past its first rows (nothing above would say they are hidden; `⋮` only marks rows below).
|
|
1392
|
+
self.top = max(0, min(self.top, len(entries) - body))
|
|
534
1393
|
if self.cursor < self.top:
|
|
535
1394
|
self.top = self.cursor
|
|
536
1395
|
if self.cursor >= self.top + body:
|
|
537
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
|
|
538
1402
|
target = self.current_target()
|
|
539
1403
|
others = {slot: self.slot_target(slot) for slot in SLOTS[1:self.viewports]}
|
|
540
1404
|
out = ['\033[?25l\033[H']
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
1405
|
+
tone = self.tone
|
|
1406
|
+
marks, reset = self.glyphs['marks'], self.tone['reset']
|
|
1407
|
+
# Header (spec §4.3): the title, then the agent glyph counts ending at column 35 (column 36 blank), each in
|
|
1408
|
+
# its tone — the same glyphs as column 2, so they are the legend. No agents: `{n} lanes`, ink-dim. While the
|
|
1409
|
+
# health banner is up the counts are ink-dim too: a frozen status.json is not live.
|
|
1410
|
+
tally = self.header_tally()
|
|
1411
|
+
right = ' '.join(text for _status, text in tally) or f'{len(self.entries)} lanes'
|
|
1412
|
+
right_width = cell_width(right)
|
|
1413
|
+
header, used = clip(' Add to workspace' if self.registration else f' {self.title}', max(1, cols - right_width - 2))
|
|
1414
|
+
if tally and not self.banner:
|
|
1415
|
+
painted = ' '.join(f'{tone[AGENT_GLYPHS[status][2]]}{text}{reset}' for status, text in tally)
|
|
1416
|
+
else:
|
|
1417
|
+
painted = f'{tone["ink-dim"]}{right}{reset}'
|
|
1418
|
+
# Column 36 is never written, not even with a blank (the budget gate's cell walk, DEF-1417): `\033[K` clears it.
|
|
1419
|
+
out.append(f'{tone["bold"]}{header}{reset}{" " * max(1, cols - 1 - used - right_width)}{painted}\033[K\r\n')
|
|
1420
|
+
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
|
+
g = marks
|
|
1426
|
+
sep, q0, q1 = f' {g["sep"]} ', g['quote-open'], g['quote-close']
|
|
1427
|
+
if self.showing_keys: # spec §4.7: `?` replaces the body with the key sheet until any key
|
|
1428
|
+
out.extend(f'{line}\033[K\r\n' for line in self.key_sheet(cols, body))
|
|
1429
|
+
for row in range(0 if self.showing_keys else body):
|
|
545
1430
|
index = self.top + row
|
|
1431
|
+
if row == 0 and self.filter and not entries: # spec §4.7: filter with no hits
|
|
1432
|
+
label, _ = clip(f' no lane matches {q0}{self.filter}{q1}{sep}Esc', cols - 1)
|
|
1433
|
+
out.append(f'{tone["ink-dim"]}{label}{reset}\033[K\r\n')
|
|
1434
|
+
continue
|
|
546
1435
|
if index >= len(entries):
|
|
547
1436
|
out.append('\033[K\r\n')
|
|
548
1437
|
continue
|
|
1438
|
+
if more and row == body - 1: # spec §4.7: the list continues — ` ⋮ N more`, the hidden rows counted
|
|
1439
|
+
label, _ = clip(f' {marks["more"]} {len(entries) - index} more', cols - 1)
|
|
1440
|
+
out.append(f'{tone["ink-dim"]}{label}{reset}\033[K\r\n')
|
|
1441
|
+
continue
|
|
549
1442
|
entry = entries[index]
|
|
550
1443
|
if entry.get('divider'):
|
|
551
|
-
|
|
552
|
-
out.append(
|
|
1444
|
+
section = entry.get('section')
|
|
1445
|
+
out.append(self.divider_line(entry, cols) + '\033[K\r\n')
|
|
553
1446
|
continue
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
1447
|
+
is_cursor = index == self.cursor
|
|
1448
|
+
cursor = f'{tone["accent"]}{marks["cursor"]}{reset}' if is_cursor else ' '
|
|
1449
|
+
if entry.get('action') == 'new-shell' and not self.entries and not self.filter:
|
|
1450
|
+
# spec §4.7: an empty workspace says so on the New shell row itself (still the row that creates one)
|
|
1451
|
+
label, used_cols = clip(f' no lanes declared{sep}{g["new"]} New shell', max(1, cols - 2))
|
|
1452
|
+
out.append(f'{cursor}{tone["ink-dim"]}{label}{reset}{" " * max(0, cols - 2 - used_cols)}\033[K\r\n')
|
|
558
1453
|
continue
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
if
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
if age_s >= 60:
|
|
581
|
-
age_suffix = f' {age_s // 60}m'
|
|
582
|
-
elif age_s >= 10:
|
|
583
|
-
age_suffix = f' {age_s}s'
|
|
584
|
-
name, name_used = clip(entry.get('name', entry['id']) + shown + age_suffix, name_width)
|
|
585
|
-
color = entry.get('color')
|
|
586
|
-
style = ('1;' if is_target else '') + ('4;' if is_cursor else '')
|
|
587
|
-
fg = f'38;5;{color}' if isinstance(color, int) else '39'
|
|
588
|
-
background = '' # theme-agnostic: the cursor row is marked by ▌ + underline, not a fixed color
|
|
589
|
-
pad = ' ' * max(0, name_width - name_used)
|
|
590
|
-
close = f'\033[{max(1, cols - 2)}G\033[31m×\033[0m' if entry.get('closable') else ''
|
|
591
|
-
out.append(f'{background}\033[36m{marker}\033[39m{icon}{icon_pad}\033[{style}{fg}m{name}\033[22;24;39m{pad} '
|
|
592
|
-
f'\033[{glyph_color}m{glyph}\033[0m{background}{tail}\033[0m{close}\033[K\r\n')
|
|
593
|
-
footer_style = '2'
|
|
594
|
-
if self.renaming:
|
|
595
|
-
footer = f' Rename: {self.rename_value}█'
|
|
596
|
-
footer_style = '36'
|
|
1454
|
+
if entry.get('action') == 'new-shell':
|
|
1455
|
+
# ` + New shell` (spec §4.8): the `new` mark in column 2 (tone-done), the label ink-dim, padded to 35.
|
|
1456
|
+
used_cols = 4 + cell_width('New shell')
|
|
1457
|
+
out.append(f'{cursor}{tone["tone-done"]}{marks["new"]}{reset} {tone["ink-dim"]}New shell{reset}'
|
|
1458
|
+
f'{" " * max(0, cols - 1 - used_cols)}\033[K\r\n')
|
|
1459
|
+
continue
|
|
1460
|
+
if entry.get('action'): # the Add-to-workspace picker's choices and its Cancel
|
|
1461
|
+
label, used_cols = clip(entry['name'], max(1, cols - 4))
|
|
1462
|
+
out.append(f'{cursor} {tone["tone-done"]}{label}{reset}{" " * max(0, cols - 4 - used_cols)}\033[K\r\n')
|
|
1463
|
+
continue
|
|
1464
|
+
out.append(self.row(entry, index, target, others, cols, now,
|
|
1465
|
+
'tone-attention' if section == 'needs' else None) + '\033[K\r\n')
|
|
1466
|
+
# The footer ladder (spec §4.6(a)): confirm › rename › promote › (picker) › filter › banner › registry error ›
|
|
1467
|
+
# message › legend. Every prompt has one shape — the `lead` mark, a sentence, `y/n` or the caret — and every
|
|
1468
|
+
# separator, quote, `⏎`, `›` and `⚠` is a mark lookup, so the ASCII footer is seven-bit.
|
|
1469
|
+
lead, caret = g['lead'], ''
|
|
1470
|
+
if self.confirming:
|
|
1471
|
+
footer, footer_style = f" {lead} close {q0}{self.confirming.get('name', '')}{q1}? y/n", tone['tone-urgent']
|
|
1472
|
+
elif self.renaming:
|
|
1473
|
+
footer, footer_style = f' {lead} Rename: {self.rename_value}', tone['ink']
|
|
1474
|
+
caret = f'{tone["tone-working"]}{g["caret"]}{reset}'
|
|
597
1475
|
elif self.promoting:
|
|
598
|
-
name = self.promote_entry.get('name', '')
|
|
599
|
-
footer = f
|
|
600
|
-
|
|
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']
|
|
601
1480
|
elif self.filtering or self.filter:
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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']
|
|
605
1486
|
elif self.errors:
|
|
606
|
-
footer = '
|
|
1487
|
+
footer, footer_style = f' {g["warning"]} registry: ' + self.errors[0], tone['tone-urgent']
|
|
607
1488
|
elif self.message:
|
|
608
|
-
footer = ' ' + 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']
|
|
609
1492
|
else:
|
|
610
|
-
footer = '
|
|
611
|
-
footer, _ = clip(footer, cols - 1)
|
|
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 ''
|
|
612
1496
|
if self.box:
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1497
|
+
out.append(self.strip_line(cols) + '\033[K\r\n')
|
|
1498
|
+
if self.strip_row: # spec §4.6(c): the fleet economy line first; without one, fleet attention
|
|
1499
|
+
out.append((economy_line or self.fleet_attention(cols - 2)) + '\033[K\r\n')
|
|
1500
|
+
out.append(f'{tone["ink-faint"]}{g["rule"] * cols}{reset}\r\n{footer_style}{footer}{reset}\033[K\033[J')
|
|
1501
|
+
out.append(self.osc_bytes(target)) # title / notification strings: after the footer, same write (§4.9)
|
|
616
1502
|
sys.stdout.write(''.join(out))
|
|
617
1503
|
sys.stdout.flush()
|
|
618
1504
|
self.dirty = False
|
|
619
1505
|
|
|
620
1506
|
def render_mobile(self, cols, rows):
|
|
621
|
-
"""Three terminal rows per lane make the entire row a finger-sized touch target.
|
|
622
|
-
|
|
1507
|
+
"""Three terminal rows per lane make the entire row a finger-sized touch target (spec §4.10): the name with
|
|
1508
|
+
its agent glyph, then `label · age · harness`, then the agent's task."""
|
|
1509
|
+
entries = self.follow()
|
|
623
1510
|
self.box = False # the Mac file-drop helper is not available in a phone browser
|
|
624
1511
|
self.entry_top, self.entry_height = 3, 3 if rows >= 8 else 1
|
|
625
1512
|
body = max(1, (rows - 3) // self.entry_height)
|
|
626
1513
|
self.box_top = self.entry_top + body * self.entry_height
|
|
627
|
-
self.
|
|
628
|
-
self.top = max(0, min(self.top, self.cursor))
|
|
1514
|
+
self.top = max(0, min(self.top, self.cursor, len(entries) - body)) # bounded by the list, as in render()
|
|
629
1515
|
if self.cursor >= self.top + body:
|
|
630
1516
|
self.top = self.cursor - body + 1
|
|
631
1517
|
target = self.current_target()
|
|
1518
|
+
if self.theme_pending and time.monotonic() >= self.theme_deadline:
|
|
1519
|
+
self.apply_theme(self.variant)
|
|
632
1520
|
out = ['\033[?25l\033[H']
|
|
1521
|
+
tone, marks = self.tone, self.glyphs['marks']
|
|
1522
|
+
reset = tone['reset']
|
|
633
1523
|
|
|
634
|
-
def line(value='', style='
|
|
635
|
-
value, _ = clip(value, max(1, cols - (5 if closable else 1)))
|
|
636
|
-
close = f'\033[{max(1, cols - 2)}G
|
|
637
|
-
|
|
1524
|
+
def line(value='', style='', closable=False, can_register=False):
|
|
1525
|
+
value, _ = clip(value, max(1, cols - (9 if can_register else 5 if closable else 1)))
|
|
1526
|
+
close = f'\033[{max(1, cols - 2)}G{tone["tone-urgent"]}{marks["close"]}{reset}' if closable else ''
|
|
1527
|
+
add = f'\033[{max(1, cols - 6)}G{tone["tone-done"]}{marks["new"]}{reset}' if can_register else ''
|
|
1528
|
+
out.append(f'{reset}{style}{value}{reset}{add}{close}\033[K\r\n')
|
|
638
1529
|
|
|
639
|
-
|
|
640
|
-
|
|
1530
|
+
# Header (spec §4.10): the agent counts in words, bold ink — no cyan, no `LANES` banner: the phone shell's own
|
|
1531
|
+
# header carries identity. No agents: ` {n} lanes`, ink-dim, as on the desktop (§4.3).
|
|
1532
|
+
counts = '' if self.registration else self.mobile_counts()
|
|
1533
|
+
if self.registration:
|
|
1534
|
+
line(' ADD TO WORKSPACE', tone['bold'])
|
|
1535
|
+
else:
|
|
1536
|
+
line(counts or f' {len(self.entries)} lanes', tone['ink-dim'] if self.banner or not counts else tone['bold'])
|
|
641
1537
|
line()
|
|
1538
|
+
now, sep = time.time(), f' {marks["sep"]} ' # every ` · ` is the sep mark: the ASCII frame stays seven-bit
|
|
642
1539
|
for index in range(self.top, self.top + body):
|
|
643
1540
|
if index >= len(entries):
|
|
644
1541
|
for _ in range(self.entry_height):
|
|
@@ -647,42 +1544,85 @@ class Sidebar:
|
|
|
647
1544
|
entry = entries[index]
|
|
648
1545
|
observed = self.status.get(entry['id'], {})
|
|
649
1546
|
lane_state = observed.get('state', 'helper' if entry.get('holder') == 'exec' else 'unknown')
|
|
650
|
-
|
|
651
|
-
|
|
1547
|
+
agent = observed.get('agent') if isinstance(observed.get('agent'), dict) else {}
|
|
1548
|
+
status = agent.get('status') if agent.get('status') in AGENT_GLYPHS else None
|
|
1549
|
+
stale = bool(observed.get('stale'))
|
|
1550
|
+
if entry.get('divider'): # section headers as desktop (§4.4), three rows tall: the touch grid
|
|
1551
|
+
out.append(self.divider_line(entry, cols) + '\033[K\r\n')
|
|
1552
|
+
elif entry.get('action') == 'new-shell':
|
|
1553
|
+
line(f' {marks["new"]} New shell', tone['tone-done'])
|
|
1554
|
+
elif entry.get('action'): # the Add-to-workspace picker's choices and its Cancel
|
|
1555
|
+
line(f' {entry["name"]}', tone['bold'] + tone['tone-done'])
|
|
1556
|
+
else:
|
|
1557
|
+
# Row A: column 2 the viewing mark (accent), column 3 the agent glyph in its tone (ink-dim while the
|
|
1558
|
+
# status is stale), the name bold in its identity colour. The `›` touch cursor is gone (the cursor
|
|
1559
|
+
# still moves with J/K). The viewer dots are the sidebar's rule, not the desktop render's: the phone
|
|
1560
|
+
# is the one surface where the operator cannot also see the Studio tab (DEF-983).
|
|
1561
|
+
closable = bool(entry.get('closable'))
|
|
1562
|
+
can_register = closable and R.eligible(entry)
|
|
1563
|
+
mark = f'{tone["accent"]}{marks["viewing"]}{reset}' if entry['id'] == target else ' '
|
|
1564
|
+
glyph = ' '
|
|
1565
|
+
if status:
|
|
1566
|
+
role = 'ink-dim' if stale else tokens.tone_of(AGENT_GLYPHS, status)
|
|
1567
|
+
glyph = f'{tone[role]}{self.glyphs["agent"][status]}{reset}'
|
|
1568
|
+
viewers = viewer_mark(observed).replace('·', marks['viewer'])
|
|
1569
|
+
width = max(1, cols - 4 - (9 if can_register else 5 if closable else 1) - len(viewers))
|
|
1570
|
+
name, _ = clip(entry.get('name', entry['id']), width)
|
|
1571
|
+
color = entry.get('color')
|
|
1572
|
+
fg = f'\033[38;5;{color}m' if isinstance(color, int) and self.color != 'none' else ''
|
|
1573
|
+
close = f'\033[{max(1, cols - 2)}G{tone["tone-urgent"]}{marks["close"]}{reset}' if closable else ''
|
|
1574
|
+
add = f'\033[{max(1, cols - 6)}G{tone["tone-done"]}{marks["new"]}{reset}' if can_register else ''
|
|
1575
|
+
dots = f'{tone["ink-dim"]}{viewers}{reset}' if viewers else ''
|
|
1576
|
+
out.append(f'{reset} {mark}{glyph} {tone["bold"]}{fg}{name}{reset}{dots}{add}{close}\033[K\r\n')
|
|
1577
|
+
if self.entry_height == 1:
|
|
1578
|
+
continue
|
|
652
1579
|
if entry.get('divider'):
|
|
653
|
-
line(
|
|
654
|
-
|
|
655
|
-
|
|
1580
|
+
line(); line()
|
|
1581
|
+
continue
|
|
1582
|
+
# Row B: what the agent needs and since when, and which harness; a shell says its state word and its
|
|
1583
|
+
# output age. Row C: the agent's task, blank for shells. Agent text is pane-title data: control
|
|
1584
|
+
# characters are dropped so a title can never inject a sequence into the frame.
|
|
1585
|
+
task = ''
|
|
1586
|
+
if entry.get('action'):
|
|
1587
|
+
detail = {'new-shell': 'Create a persistent shell', 'register-workspace': 'Add this session',
|
|
1588
|
+
'cancel-registration': 'Keep unregistered'}.get(entry['action'], '')
|
|
1589
|
+
elif status and status != 'shell':
|
|
1590
|
+
since = agent.get('since')
|
|
1591
|
+
age = age3(now - since if isinstance(since, (int, float)) and since > 0 else None).strip()
|
|
1592
|
+
harness = OSC_UNSAFE.sub('', str(agent.get('harness') or ''))
|
|
1593
|
+
detail = sep.join(part for part in (MOBILE_AGENT_LABELS[status], age, harness) if part)
|
|
1594
|
+
task = OSC_UNSAFE.sub('', str(agent.get('name') or ''))
|
|
656
1595
|
else:
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
if
|
|
664
|
-
label = MOBILE_LABELS.get(lane_state, lane_state)
|
|
665
|
-
detail = ('Viewing · ' if selected else '') + label
|
|
666
|
-
if entry.get('action'):
|
|
667
|
-
detail = 'Create a persistent shell'
|
|
668
|
-
if observed.get('stale'):
|
|
669
|
-
detail += ' · status delayed'
|
|
670
|
-
line(' ' + detail if not entry.get('divider') else '', '2')
|
|
671
|
-
line()
|
|
1596
|
+
activity = observed.get('activity')
|
|
1597
|
+
age = age3(now - activity if isinstance(activity, (int, float)) and activity > 0 else None).strip()
|
|
1598
|
+
detail = sep.join(part for part in (MOBILE_LABELS.get(lane_state, lane_state), age) if part)
|
|
1599
|
+
if stale:
|
|
1600
|
+
detail += sep + 'status delayed'
|
|
1601
|
+
line(' ' + detail, tone['ink-dim'])
|
|
1602
|
+
line(' ' + task if task else '', tone['ink-dim'])
|
|
672
1603
|
for _ in range(max(0, rows - (2 + body * self.entry_height + 1))):
|
|
673
1604
|
line()
|
|
674
|
-
|
|
1605
|
+
q0, q1 = marks['quote-open'], marks['quote-close']
|
|
1606
|
+
footer = (f" {marks['lead']} close {q0}{self.confirming.get('name', '')}{q1}? y/n" if self.confirming else
|
|
1607
|
+
' ' + (self.message or f'Choose a workspace{sep}Tap Cancel') if self.registration else
|
|
1608
|
+
' /' + self.filter if self.filtering or self.filter else
|
|
675
1609
|
' ' + self.banner if self.banner else
|
|
676
1610
|
' ' + self.errors[0] if self.errors else
|
|
677
|
-
' ' + self.message if self.message else ' Swipe to scroll
|
|
1611
|
+
' ' + self.message if self.message else f' Swipe to scroll{sep}Tap to open')
|
|
678
1612
|
footer, _ = clip(footer, max(1, cols - 1))
|
|
679
|
-
out.append(f'
|
|
1613
|
+
out.append(f'{tone["tone-urgent"] if self.confirming else tone["ink-dim"]}{footer}{reset}\033[K\033[J')
|
|
1614
|
+
out.append(self.osc_bytes(target)) # the phone reads the counts from this pane's title; no OSC 9 here
|
|
680
1615
|
sys.stdout.write(''.join(out))
|
|
681
1616
|
sys.stdout.flush()
|
|
682
1617
|
self.dirty = False
|
|
683
1618
|
|
|
684
1619
|
# ---------------------------------------------------------------- input
|
|
685
1620
|
def handle(self, data):
|
|
1621
|
+
if isinstance(data, bytes): # the raw read, as run() gets it from the pty
|
|
1622
|
+
data = data.decode('utf-8', 'replace')
|
|
1623
|
+
# An OSC 11 answer (`\033]11;rgb:…` ST/BEL, possibly split across reads) is a theme, never an Esc: it is taken
|
|
1624
|
+
# out before anything reads the keys, so it cannot cancel a filter or a prompt (spec §2.4, Review Focus 6).
|
|
1625
|
+
data = self.strip_osc(data)
|
|
686
1626
|
# Bracketed paste (a drop, or a real paste) can span several reads: buffer until its end marker.
|
|
687
1627
|
if self.paste_buffer is not None:
|
|
688
1628
|
self.paste_buffer += data
|
|
@@ -696,10 +1636,45 @@ class Sidebar:
|
|
|
696
1636
|
self.dropped(pasted)
|
|
697
1637
|
self.dirty = True
|
|
698
1638
|
data += tail
|
|
699
|
-
entries = self.
|
|
1639
|
+
entries = self.follow()
|
|
700
1640
|
i = 0
|
|
701
1641
|
while i < len(data):
|
|
1642
|
+
if self.showing_keys:
|
|
1643
|
+
# spec §4.7: any key closes the key sheet and is consumed, never acted on. Checked per key, not per
|
|
1644
|
+
# read: one read can carry several keys (key repeat, fast typing), so `?j` opens and closes the sheet
|
|
1645
|
+
# with the j consumed, and `?xy` consumes the x — exactly one key or one whole escape sequence each.
|
|
1646
|
+
self.showing_keys, self.dirty = False, True
|
|
1647
|
+
i += key_length(data, i)
|
|
1648
|
+
continue
|
|
1649
|
+
entries = self.follow() # per key: an action may have changed the list (spec §4.5)
|
|
702
1650
|
ch = data[i]
|
|
1651
|
+
if self.confirming is not None:
|
|
1652
|
+
# `y` closes the entry that asked; anything else cancels. A key is consumed by the cancel; an escape
|
|
1653
|
+
# sequence (Esc, an arrow, a click) cancels and is then handled as usual. Only a key or a mouse PRESS
|
|
1654
|
+
# answers: under ?1000h/?1006h the terminal reports the RELEASE that follows every press (the very
|
|
1655
|
+
# release of the `×` click that opened this prompt), and wheel/motion reports are no answer either,
|
|
1656
|
+
# so those are consumed and the prompt stays up.
|
|
1657
|
+
if ch == '\033' and data[i + 1:].startswith('[<'):
|
|
1658
|
+
rest = data[i + 1:]
|
|
1659
|
+
end = next((k for k, c in enumerate(rest) if c in 'Mm'), -1)
|
|
1660
|
+
if end < 0:
|
|
1661
|
+
break # an incomplete report is no answer
|
|
1662
|
+
try:
|
|
1663
|
+
button = int(rest[2:end].split(';')[0])
|
|
1664
|
+
except ValueError:
|
|
1665
|
+
button = -1
|
|
1666
|
+
if rest[end] == 'm' or (button >= 0 and button & (32 | 64)):
|
|
1667
|
+
i += end + 2
|
|
1668
|
+
continue
|
|
1669
|
+
entry, self.confirming, self.dirty = self.confirming, None, True
|
|
1670
|
+
if ch in 'yY':
|
|
1671
|
+
self.close_lane(entry)
|
|
1672
|
+
i += 1
|
|
1673
|
+
continue
|
|
1674
|
+
self.message = 'Cancelled'
|
|
1675
|
+
if ch != '\033':
|
|
1676
|
+
i += 1
|
|
1677
|
+
continue
|
|
703
1678
|
if ch == '\033':
|
|
704
1679
|
rest = data[i + 1:]
|
|
705
1680
|
if rest.startswith('[<'): # SGR mouse
|
|
@@ -721,8 +1696,13 @@ class Sidebar:
|
|
|
721
1696
|
index = self.top + (y - self.entry_top) // self.entry_height
|
|
722
1697
|
if 0 <= index < len(entries) and self.entry_top <= y < self.box_top:
|
|
723
1698
|
self.cursor = index
|
|
724
|
-
if
|
|
725
|
-
|
|
1699
|
+
if y == self.more_row and not self.mobile:
|
|
1700
|
+
pass # `⋮ N more` hides this entry: the click scrolls it into view, never opens it
|
|
1701
|
+
elif entries[index].get('closable') and _x >= max(1, self.size[0] - 4):
|
|
1702
|
+
self.request_close(entries[index])
|
|
1703
|
+
elif (entries[index].get('closable') and R.eligible(entries[index])
|
|
1704
|
+
and _x >= max(1, self.size[0] - 8)):
|
|
1705
|
+
self.start_registration(entries[index])
|
|
726
1706
|
else:
|
|
727
1707
|
self.open_lane(entries[index])
|
|
728
1708
|
i += end + 2
|
|
@@ -736,6 +1716,10 @@ class Sidebar:
|
|
|
736
1716
|
if entries:
|
|
737
1717
|
self.open_lane(entries[self.cursor])
|
|
738
1718
|
i += 3
|
|
1719
|
+
elif rest[:2] == '[Z': # Shift-Tab: the previous lane that needs you (a prompt keeps it)
|
|
1720
|
+
if not (self.renaming or self.promoting):
|
|
1721
|
+
self.jump_needs_you(-1)
|
|
1722
|
+
i += 3
|
|
739
1723
|
elif rest[:2] == '[5':
|
|
740
1724
|
self.cursor -= 10; i += 4
|
|
741
1725
|
elif rest[:2] == '[6':
|
|
@@ -744,6 +1728,8 @@ class Sidebar:
|
|
|
744
1728
|
end = next((k for k, c in enumerate(rest[1:], 1) if '@' <= c <= '~'), len(rest) - 1)
|
|
745
1729
|
i += end + 2
|
|
746
1730
|
else:
|
|
1731
|
+
if self.registration:
|
|
1732
|
+
self.cancel_registration()
|
|
747
1733
|
if self.armed:
|
|
748
1734
|
self.armed = False
|
|
749
1735
|
self.focus_lane()
|
|
@@ -781,13 +1767,15 @@ class Sidebar:
|
|
|
781
1767
|
self.filter = self.filter[:-1]
|
|
782
1768
|
elif ch.isprintable():
|
|
783
1769
|
self.filter += ch
|
|
784
|
-
self.cursor
|
|
1770
|
+
self.set_cursor(0) # the top match of the filtered list, not the lane the cursor was on
|
|
785
1771
|
elif ch in '\r\n ':
|
|
786
1772
|
if entries:
|
|
787
1773
|
self.open_lane(entries[self.cursor])
|
|
788
1774
|
elif ch in '23' and self.viewports > 1:
|
|
789
1775
|
if entries:
|
|
790
1776
|
self.open_lane(entries[self.cursor], SLOTS[int(ch) - 1])
|
|
1777
|
+
elif ch == '\t': # Tab: the next lane that needs you (spec §4.5)
|
|
1778
|
+
self.jump_needs_you(+1)
|
|
791
1779
|
elif ch in 'JK': # open the next / previous lane relative to the one being shown
|
|
792
1780
|
lanes = [e for e in entries if not e.get('divider')]
|
|
793
1781
|
if lanes:
|
|
@@ -806,13 +1794,16 @@ class Sidebar:
|
|
|
806
1794
|
elif ch == 'G':
|
|
807
1795
|
self.cursor = len(entries) - 1
|
|
808
1796
|
elif ch == '/':
|
|
809
|
-
self.filtering, self.filter
|
|
810
|
-
|
|
1797
|
+
self.filtering, self.filter = True, ''
|
|
1798
|
+
self.set_cursor(0)
|
|
1799
|
+
elif ch == 'U' and not self.attach_only:
|
|
811
1800
|
self.armed = True
|
|
812
|
-
elif ch == '+':
|
|
1801
|
+
elif ch == '+' and not self.registration and not self.attach_only:
|
|
813
1802
|
self.new_shell()
|
|
1803
|
+
elif ch == 'a' and entries:
|
|
1804
|
+
self.start_registration(entries[self.cursor])
|
|
814
1805
|
elif ch == 'x' and entries:
|
|
815
|
-
self.
|
|
1806
|
+
self.request_close(entries[min(self.cursor, len(entries) - 1)])
|
|
816
1807
|
elif ch == 'R' and entries:
|
|
817
1808
|
entry = entries[min(self.cursor, len(entries) - 1)]
|
|
818
1809
|
self.start_rename(entry)
|
|
@@ -821,15 +1812,26 @@ class Sidebar:
|
|
|
821
1812
|
self.start_promote(entry)
|
|
822
1813
|
elif ch == 'r':
|
|
823
1814
|
self.reload(force=True)
|
|
1815
|
+
elif ch == '?':
|
|
1816
|
+
self.showing_keys = True
|
|
824
1817
|
self.dirty = True
|
|
825
1818
|
i += 1
|
|
826
1819
|
|
|
1820
|
+
def start_bytes(self):
|
|
1821
|
+
"""What run() writes once at start: alternate screen, mouse, bracketed paste — and, under LANES_THEME=auto, the
|
|
1822
|
+
ONE OSC 11 background query (spec §2.4: no re-ask; handle() parses the answer, the grace starts now)."""
|
|
1823
|
+
out = '\033[?1049h\033[?1000h\033[?1006h\033[?2004h\033[2J'
|
|
1824
|
+
if self.theme_pending:
|
|
1825
|
+
out += OSC11_QUERY
|
|
1826
|
+
self.theme_deadline = time.monotonic() + THEME_GRACE
|
|
1827
|
+
return out
|
|
1828
|
+
|
|
827
1829
|
def run(self):
|
|
828
1830
|
fd = sys.stdin.fileno()
|
|
829
1831
|
saved = termios.tcgetattr(fd)
|
|
830
1832
|
try:
|
|
831
1833
|
tty.setraw(fd)
|
|
832
|
-
sys.stdout.write(
|
|
1834
|
+
sys.stdout.write(self.start_bytes())
|
|
833
1835
|
self.reload(force=True)
|
|
834
1836
|
target = self.current_target()
|
|
835
1837
|
for index, entry in enumerate(self.visible()):
|
|
@@ -847,11 +1849,13 @@ class Sidebar:
|
|
|
847
1849
|
last_size, self.dirty = size, True
|
|
848
1850
|
self.poll_upload()
|
|
849
1851
|
self.poll_health()
|
|
850
|
-
|
|
1852
|
+
self.poll_economy()
|
|
1853
|
+
# An upload in flight no longer forces a frame: poll_upload() dirties on progress and on the end.
|
|
1854
|
+
if self.armed or (self.upload_note and time.time() - self.upload_note_at < 9):
|
|
851
1855
|
self.dirty = True
|
|
852
1856
|
if self.dirty:
|
|
853
1857
|
self.render()
|
|
854
|
-
ready, _, _ = select.select([fd], [], [],
|
|
1858
|
+
ready, _, _ = select.select([fd], [], [], self.tick_timeout())
|
|
855
1859
|
if ready:
|
|
856
1860
|
data = os.read(fd, 4096)
|
|
857
1861
|
if not data:
|