@vibelord/lanes 1.0.0 → 1.0.1
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 +56 -1
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +281 -104
- package/payload/lanes/client/lanes-launch +44 -2
- package/payload/lanes/container/env.sh +8 -0
- package/payload/lanes/container/lane +19 -2
- package/payload/lanes/docs/ARCHITECTURE.md +86 -1
- package/payload/lanes/docs/DESIGN.md +72 -0
- package/payload/lanes/docs/INSTALL.md +39 -0
- package/payload/lanes/docs/RUNBOOK.md +94 -1
- package/payload/lanes/holders/ssh.sh +18 -0
- package/payload/lanes/install/enable-phone.sh +15 -6
- package/payload/lanes/install/enable-web.sh +19 -6
- package/payload/lanes/libexec/agent_state.py +214 -0
- package/payload/lanes/libexec/connection.py +178 -0
- package/payload/lanes/libexec/cursor_sync.py +139 -6
- package/payload/lanes/libexec/device_sync.py +134 -0
- package/payload/lanes/libexec/lanes_lib.py +158 -5
- package/payload/lanes/libexec/registrations.py +148 -0
- package/payload/lanes/libexec/session_actions.py +47 -15
- package/payload/lanes/libexec/sidebar.py +372 -61
- package/payload/lanes/libexec/statusd.py +126 -33
- package/payload/lanes/libexec/tokens.py +279 -0
- package/payload/lanes/libexec/tokens.sh +11 -0
- package/payload/lanes/libexec/view.sh +9 -8
- package/payload/lanes/web/README.md +15 -0
- package/payload/lanes/web/browser.css +22 -0
- package/payload/lanes/web/browser.js +73 -0
- package/payload/lanes/web/mobile.css +65 -33
- package/payload/lanes/web/mobile.js +23 -5
- package/payload/lanes/workspaces.example/README.md +9 -3
- package/payload/lanes/zellij/config.kdl +61 -3
- package/payload/manifest.json +6 -3
|
@@ -5,7 +5,9 @@ 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)
|
|
8
|
+
2 / 3 open the lane under the cursor in the 2nd / 3rd viewport (wide view sessions) · x / × close (asks y/n)
|
|
9
|
+
Attention first: lanes whose agent needs you, is working, or finished unseen sort into sections above the rest; the
|
|
10
|
+
agent glyph sits in column 2, and every row carries an age (agent `since`, else last output).
|
|
9
11
|
Upload box (bottom): click it or press U (Ctrl-Alt-u from anywhere), then DROP FILES anywhere on the window. A
|
|
10
12
|
terminal delivers a drop as a paste of the LOCAL path into the focused pane, so the box must have focus first; the
|
|
11
13
|
file itself is then sent by the upload helper on your machine (it only reads folders you allowed there).
|
|
@@ -23,6 +25,7 @@ import subprocess
|
|
|
23
25
|
import sys
|
|
24
26
|
import termios
|
|
25
27
|
import time
|
|
28
|
+
from operator import itemgetter
|
|
26
29
|
from pathlib import Path
|
|
27
30
|
import tty
|
|
28
31
|
import unicodedata
|
|
@@ -30,8 +33,40 @@ import unicodedata
|
|
|
30
33
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
34
|
import lanes_lib as L # noqa: E402
|
|
32
35
|
import session_actions as A # noqa: E402
|
|
36
|
+
import registrations as R # noqa: E402
|
|
37
|
+
import tokens # noqa: E402
|
|
33
38
|
|
|
34
39
|
STATE = os.environ.get('LANES_STATE') or os.path.expanduser('~/.local/state/lanes')
|
|
40
|
+
# What the harness in a lane is doing (statusd's additive `agent{}`, Task 4/5 vocabulary): `key -> (unicode, ascii,
|
|
41
|
+
# tone)` from the one token source, drawn in column 2 (spec §3). A SEPARATE dict: GLYPHS' keys stay statusd's words.
|
|
42
|
+
AGENT_GLYPHS = tokens.AGENT_GLYPHS
|
|
43
|
+
# The attention queue (spec §4.4): (section key, title, the agent status whose glyph heads the section, members in
|
|
44
|
+
# their order of urgency). Needs you = a blocking prompt, a failure, a finished turn waiting for a prompt.
|
|
45
|
+
ATTENTION = (('needs', 'Needs you', 'needs_approval', ('needs_approval', 'errored', 'needs_input')),
|
|
46
|
+
('working', 'Working', 'working', ('working',)),
|
|
47
|
+
('done', 'Done', 'done', ('done',)))
|
|
48
|
+
HEADER_COUNTS = ('needs_approval', 'needs_input', 'working', 'done') # the header's glyph counts (spec §4.3)
|
|
49
|
+
SECTION_OF = {status: key for key, _title, _glyph, members in ATTENTION for status in members}
|
|
50
|
+
NEEDS_RANK = {status: rank for rank, status in enumerate(ATTENTION[0][3])}
|
|
51
|
+
INF = float('inf')
|
|
52
|
+
# Tones by role name. Until Task 22 resolves LANES_COLOR/LANES_THEME per device this is the 16-colour table (the
|
|
53
|
+
# user's own palette); new code paints through role names so Task 22 swaps the table, not the call sites.
|
|
54
|
+
TONE16 = tokens.sgr_table('16', 'dark')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def age3(seconds):
|
|
58
|
+
"""Three cells, right-aligned: how long. None/negative = nothing known = three blanks (spec §4.2)."""
|
|
59
|
+
if seconds is None or seconds < 0:
|
|
60
|
+
return ' '
|
|
61
|
+
if seconds < 60:
|
|
62
|
+
return f'{int(seconds):2d}s'
|
|
63
|
+
if seconds < 3600:
|
|
64
|
+
return f'{int(seconds // 60):2d}m'
|
|
65
|
+
if seconds < 86400:
|
|
66
|
+
return f'{int(seconds // 3600):2d}h'
|
|
67
|
+
return f'{min(99, int(seconds // 86400)):2d}d'
|
|
68
|
+
|
|
69
|
+
|
|
35
70
|
# The six words statusd publishes (alignment contract A3) and the helper word for an `exec` lane. There is no
|
|
36
71
|
# seventh: statusd's zmx observer used to publish `idle`, which no other surface knew (DEF-823).
|
|
37
72
|
GLYPHS = {'active': ('●', '32'), 'quiet': ('○', '2'), 'missing': ('✖', '31'), 'absent': ('–', '2'),
|
|
@@ -113,6 +148,18 @@ PASTE_START, PASTE_END = '\033[200~', '\033[201~'
|
|
|
113
148
|
|
|
114
149
|
|
|
115
150
|
class Sidebar:
|
|
151
|
+
# Class-level defaults for the registration state, so a Sidebar built without `__init__` (the
|
|
152
|
+
# alignment gate constructs one with `__new__` and sets only the fields its rule needs) reads the
|
|
153
|
+
# 'no registration in progress' shape rather than raising; `__init__` assigns the live values.
|
|
154
|
+
registration, registration_choices, workspaces = None, (), ()
|
|
155
|
+
renaming, rename_value, rename_entry = False, '', None
|
|
156
|
+
promoting, promote_entry = False, None
|
|
157
|
+
attach_only = False
|
|
158
|
+
confirming = None # the entry `x` / `×` asked to close; `y` closes it, any other key cancels
|
|
159
|
+
seen = {} # read-only default; mark_seen() always assigns a fresh dict
|
|
160
|
+
tone = TONE16
|
|
161
|
+
last_visible = () # the list visible() last returned: the one `self.cursor` indexes
|
|
162
|
+
|
|
116
163
|
def __init__(self, device, workspace, slot='main', viewports='1'):
|
|
117
164
|
self.device, self.workspace, self.slot = device, workspace, slot
|
|
118
165
|
self.mobile = bool((read_json(os.path.join(STATE, 'devices', device + '.json')) or {}).get('mobile'))
|
|
@@ -129,13 +176,22 @@ class Sidebar:
|
|
|
129
176
|
self.health_path = os.path.join(STATE, 'health')
|
|
130
177
|
self.health, self.health_stamp, self.banner = {}, None, ''
|
|
131
178
|
self.entries, self.title, self.errors = [], workspace, []
|
|
179
|
+
self.attach_only = False
|
|
180
|
+
self.workspaces, self.registration, self.registration_choices = [], None, []
|
|
132
181
|
self.status, self.unregistered, self.zmx = {}, [], []
|
|
133
182
|
self.cursor, self.top = 0, 0
|
|
134
183
|
self.filter, self.filtering = '', False
|
|
135
184
|
self.renaming, self.rename_value, self.rename_entry = False, '', None
|
|
136
185
|
self.promoting, self.promote_entry = False, None
|
|
186
|
+
self.confirming = None
|
|
187
|
+
self.tone = TONE16
|
|
188
|
+
# `done` is sticky until the lane is opened from this device: lane id -> the time it was last opened. Read
|
|
189
|
+
# once here (never per tick: spec §8's stat budget), written only by mark_seen().
|
|
190
|
+
self.seen_path = os.path.join(STATE, 'clients', device + '.seen.json')
|
|
191
|
+
seen = read_json(self.seen_path)
|
|
192
|
+
self.seen = {k: v for k, v in seen.items() if isinstance(k, str) and isinstance(v, (int, float))} \
|
|
193
|
+
if isinstance(seen, dict) else {}
|
|
137
194
|
self.stamp = (0, 0, 0)
|
|
138
|
-
self._prev_entry_count = -1 # for scroll memory
|
|
139
195
|
self.size = (0, 0)
|
|
140
196
|
self.dirty = True
|
|
141
197
|
self.message = ''
|
|
@@ -161,9 +217,15 @@ class Sidebar:
|
|
|
161
217
|
sum(mtime(self.slot_file(slot, 'target')) for slot in SLOTS[:self.viewports]))
|
|
162
218
|
if stamp == self.stamp and not force:
|
|
163
219
|
return
|
|
220
|
+
# The cursor follows the LANE, not the index (spec §4.5, §10): a lane that changes section moves rows above
|
|
221
|
+
# the cursor, so remember which lane it is on and re-find it after the reload. `last_visible` is the list the
|
|
222
|
+
# cursor indexes (render() and handle() compute it before they use the cursor) — no second sectioning pass.
|
|
223
|
+
before = self.last_visible
|
|
224
|
+
cursor_id = before[self.cursor].get('id') if 0 <= self.cursor < len(before) else ''
|
|
164
225
|
if stamp[0] != self.stamp[0] or force:
|
|
165
226
|
registry = read_json(self.registry_path) or {}
|
|
166
227
|
boards = registry.get('workspaces', [])
|
|
228
|
+
self.workspaces = boards
|
|
167
229
|
self.mobile_owner = self.mobile and bool(boards) and boards[0]['id'] == self.workspace
|
|
168
230
|
self.mobile_titles = {w.get('title') or w['id']: w['id'] for w in boards}
|
|
169
231
|
self.mobile_titles.update({tool['tab']: tool['view'] for tool in registry.get('tools', [])})
|
|
@@ -172,6 +234,7 @@ class Sidebar:
|
|
|
172
234
|
self.title = workspace.get('title') or self.workspace
|
|
173
235
|
self.entries = list(workspace.get('lanes', [])) + list(workspace.get('extras', []))
|
|
174
236
|
self.errors = workspace.get('errors', [])
|
|
237
|
+
self.attach_only = bool(workspace.get('attach_only'))
|
|
175
238
|
break
|
|
176
239
|
if stamp[1] != self.stamp[1] or force:
|
|
177
240
|
status = read_json(self.status_path) or {}
|
|
@@ -180,12 +243,14 @@ class Sidebar:
|
|
|
180
243
|
self.zmx = (status.get('zmx') or {}).get(self.workspace, [])
|
|
181
244
|
self.stamp = stamp
|
|
182
245
|
self.dirty = True
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
|
|
186
|
-
if
|
|
187
|
-
|
|
188
|
-
|
|
246
|
+
# `top` is kept (no reset when the list length changes) and shifted by the rows inserted above the cursor, so
|
|
247
|
+
# the cursor's lane stays on its screen row; render() still clamps both on every draw.
|
|
248
|
+
after = self.visible() # also refreshes last_visible for the next reload
|
|
249
|
+
if cursor_id:
|
|
250
|
+
index = next((i for i, e in enumerate(after) if e.get('id') == cursor_id), None)
|
|
251
|
+
if index is not None and index != self.cursor:
|
|
252
|
+
self.top = max(0, self.top + index - self.cursor)
|
|
253
|
+
self.cursor = index
|
|
189
254
|
|
|
190
255
|
def poll_mobile_view(self):
|
|
191
256
|
"""One sidebar observes the phone's web client and the first display of each full-width view.
|
|
@@ -238,16 +303,131 @@ class Sidebar:
|
|
|
238
303
|
except OSError:
|
|
239
304
|
return ''
|
|
240
305
|
|
|
306
|
+
# ------------------------------------------------------------ attention
|
|
307
|
+
def agent_of(self, lane_id):
|
|
308
|
+
"""The lane's `agent{}` from status.json, or {} (a plain shell, or a statusd that does not publish it)."""
|
|
309
|
+
agent = (self.status.get(lane_id) or {}).get('agent')
|
|
310
|
+
return agent if isinstance(agent, dict) else {}
|
|
311
|
+
|
|
312
|
+
def seen_done(self, lane_id):
|
|
313
|
+
"""True when this lane's `done` is not news any more: it was opened at or after the turn finished."""
|
|
314
|
+
since = self.agent_of(lane_id).get('since')
|
|
315
|
+
return lane_id in self.seen and (since if isinstance(since, (int, float)) else 0) <= self.seen[lane_id]
|
|
316
|
+
|
|
317
|
+
def mark_seen(self, lane_id):
|
|
318
|
+
"""Record that the operator opened this lane (only `open_lane` calls it, spec §10): a `done` turn that
|
|
319
|
+
finished before now leaves the Done section; a later one is news again. Persisted per device, bounded to
|
|
320
|
+
the lanes status.json knows, so a restarted sidebar does not resurrect what was already looked at.
|
|
321
|
+
Every workspace tab of a device runs its own sidebar over the SAME file, so a write merges what is on disk
|
|
322
|
+
(the later mark per lane wins) instead of replacing it with this process's view."""
|
|
323
|
+
since = self.agent_of(lane_id).get('since')
|
|
324
|
+
disk = read_json(self.seen_path)
|
|
325
|
+
seen = {k: v for k, v in (disk.items() if isinstance(disk, dict) else ())
|
|
326
|
+
if isinstance(k, str) and isinstance(v, (int, float))}
|
|
327
|
+
for key, value in self.seen.items():
|
|
328
|
+
seen[key] = max(value, seen.get(key, value))
|
|
329
|
+
seen = {k: v for k, v in seen.items() if k in self.status}
|
|
330
|
+
seen[lane_id] = max(time.time(), since if isinstance(since, (int, float)) else 0)
|
|
331
|
+
self.seen = seen
|
|
332
|
+
try:
|
|
333
|
+
L.atomic_write(Path(self.seen_path), json.dumps(seen, sort_keys=True) + '\n')
|
|
334
|
+
except OSError:
|
|
335
|
+
pass # the queue still works for this process; only a restart would forget
|
|
336
|
+
|
|
337
|
+
def agent_rows(self, lanes):
|
|
338
|
+
"""[(entry, agent status or None, since or None, attention key or None)] in ONE pass over status.json — the
|
|
339
|
+
sections, the counts and the header all read this, so a render looks each lane up once (spec §4.11)."""
|
|
340
|
+
if not lanes:
|
|
341
|
+
return []
|
|
342
|
+
status_of, seen, out = self.status, self.seen, []
|
|
343
|
+
for entry in lanes:
|
|
344
|
+
agent = (status_of.get(entry.get('id', '')) or {}).get('agent')
|
|
345
|
+
status = agent.get('status') if isinstance(agent, dict) else None
|
|
346
|
+
if status not in AGENT_GLYPHS:
|
|
347
|
+
out.append((entry, None, None, None))
|
|
348
|
+
continue
|
|
349
|
+
since = agent.get('since')
|
|
350
|
+
since = since if isinstance(since, (int, float)) else None
|
|
351
|
+
key = SECTION_OF.get(status)
|
|
352
|
+
if key == 'done' and entry['id'] in seen and (since or 0) <= seen[entry['id']]:
|
|
353
|
+
key = None # opened since it finished: not news (seen_done)
|
|
354
|
+
out.append((entry, status, since, key))
|
|
355
|
+
return out
|
|
356
|
+
|
|
357
|
+
def attention(self, entry):
|
|
358
|
+
"""The attention section key of a registry entry, or None (the remainder)."""
|
|
359
|
+
return self.agent_rows([entry])[0][3]
|
|
360
|
+
|
|
361
|
+
def counts(self):
|
|
362
|
+
"""{needs, working, agents, shells} over this workspace's registry lanes: `needs` and `working` are the
|
|
363
|
+
sizes of those sections, `agents` the lanes running a harness (any status but `shell`), `shells` the rest."""
|
|
364
|
+
needs = working = agents = 0
|
|
365
|
+
for _entry, status, _since, key in self.agent_rows(self.entries):
|
|
366
|
+
needs += key == 'needs'
|
|
367
|
+
working += key == 'working'
|
|
368
|
+
agents += status is not None and status != 'shell'
|
|
369
|
+
return {'needs': needs, 'working': working, 'agents': agents, 'shells': len(self.entries) - agents}
|
|
370
|
+
|
|
371
|
+
def header_tally(self):
|
|
372
|
+
"""[(agent status, 'glyph+count')] for the header, in `HEADER_COUNTS` order, zeros omitted; a seen `done`
|
|
373
|
+
is not counted (it left the Done section)."""
|
|
374
|
+
counts = {}
|
|
375
|
+
for _entry, status, _since, key in self.agent_rows(self.entries):
|
|
376
|
+
if status in HEADER_COUNTS and (status != 'done' or key):
|
|
377
|
+
counts[status] = counts.get(status, 0) + 1
|
|
378
|
+
return [(k, f'{AGENT_GLYPHS[k][0]}{counts[k]}') for k in HEADER_COUNTS if counts.get(k)]
|
|
379
|
+
|
|
380
|
+
def header_counts(self):
|
|
381
|
+
"""The header's right side, plain: `◆1 ◇2 ▸6 ✓1`, zeros omitted, '' when no lane has one (spec §4.3)."""
|
|
382
|
+
return ' '.join(text for _status, text in self.header_tally())
|
|
383
|
+
|
|
384
|
+
def sectioned(self, lanes):
|
|
385
|
+
"""Registry lanes in attention order (spec §4.4): Needs you (by urgency, oldest first), Working (oldest
|
|
386
|
+
first), Done (newest first, unseen only), then the rest in registry order under a workspace header — drawn
|
|
387
|
+
only when an attention section exists, so a workspace without agents looks exactly as before."""
|
|
388
|
+
buckets, rest = {key: [] for key, *_ in ATTENTION}, []
|
|
389
|
+
for entry, status, since, key in self.agent_rows(lanes):
|
|
390
|
+
if key is None:
|
|
391
|
+
rest.append(entry)
|
|
392
|
+
elif key == 'needs': # urgency, then the oldest `since` first; an unknown `since` sorts last
|
|
393
|
+
buckets[key].append(((NEEDS_RANK[status], INF if since is None else since), entry))
|
|
394
|
+
elif key == 'working':
|
|
395
|
+
buckets[key].append((INF if since is None else since, entry))
|
|
396
|
+
else: # done: newest first
|
|
397
|
+
buckets[key].append((INF if since is None else -since, entry))
|
|
398
|
+
if not any(buckets.values()):
|
|
399
|
+
return rest
|
|
400
|
+
out = []
|
|
401
|
+
for key, title, _glyph, _members in ATTENTION:
|
|
402
|
+
if buckets[key]:
|
|
403
|
+
bucket = sorted(buckets[key], key=itemgetter(0))
|
|
404
|
+
out.append({'id': '', 'divider': True, 'name': f'{title} ({len(bucket)})', 'section': key})
|
|
405
|
+
out += [entry for _rank, entry in bucket]
|
|
406
|
+
if rest:
|
|
407
|
+
out.append({'id': '', 'divider': True, 'name': f'{self.title} ({len(rest)})', 'section': 'rest'})
|
|
408
|
+
return out + rest
|
|
409
|
+
|
|
241
410
|
def visible(self):
|
|
242
|
-
entries =
|
|
411
|
+
self.last_visible = entries = self._visible()
|
|
412
|
+
return entries
|
|
413
|
+
|
|
414
|
+
def _visible(self):
|
|
415
|
+
if self.registration:
|
|
416
|
+
return self.registration_choices + [{'id': '', 'name': 'Cancel', 'action': 'cancel-registration'}]
|
|
417
|
+
# Filter mode flattens the sections exactly as before (dividers are dropped below); the agent glyph stays.
|
|
418
|
+
entries = list(self.entries) if self.filter else self.sectioned(self.entries)
|
|
243
419
|
if self.zmx:
|
|
244
420
|
entries.append({'id': '', 'divider': True, 'name': f'zmx lanes ({len(self.zmx)})'})
|
|
245
421
|
entries += self.zmx
|
|
246
422
|
# 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
|
-
|
|
423
|
+
# those are orphaned or operator-made sessions that are ALIVE, and nothing alive is ever hidden —
|
|
424
|
+
# except a session that IS registered (it is listed under its workspace instead, never twice).
|
|
425
|
+
registered = {R.key(lane) for w in self.workspaces for lane in w.get('lanes', []) if lane.get('holder') == 'tmux'}
|
|
426
|
+
unregistered = [e for e in self.unregistered if not R.eligible(e) or R.key(e) not in registered]
|
|
427
|
+
if not self.attach_only:
|
|
428
|
+
entries.append({'id': '', 'divider': True, 'name': f'Unregistered ({len(unregistered)})'})
|
|
429
|
+
entries.append({'id': self.workspace + '--new-shell', 'name': 'New +', 'action': 'new-shell', 'icon': '+'})
|
|
430
|
+
entries += unregistered
|
|
251
431
|
if not self.filter:
|
|
252
432
|
return entries
|
|
253
433
|
needle = self.filter.lower()
|
|
@@ -279,6 +459,12 @@ class Sidebar:
|
|
|
279
459
|
def open_lane(self, entry, slot=None):
|
|
280
460
|
if entry.get('divider'):
|
|
281
461
|
return
|
|
462
|
+
if entry.get('action') == 'cancel-registration':
|
|
463
|
+
self.cancel_registration()
|
|
464
|
+
return
|
|
465
|
+
if entry.get('action') == 'register-workspace':
|
|
466
|
+
self.register_lane(entry['workspace'])
|
|
467
|
+
return
|
|
282
468
|
slot = slot or self.slot
|
|
283
469
|
if slot not in SLOTS[:self.viewports]:
|
|
284
470
|
self.message = 'this view has no such viewport (open a wide view: lanes-window w2)'
|
|
@@ -287,6 +473,8 @@ class Sidebar:
|
|
|
287
473
|
if entry.get('action') == 'new-shell':
|
|
288
474
|
self.new_shell(slot)
|
|
289
475
|
return
|
|
476
|
+
if self.agent_of(entry['id']):
|
|
477
|
+
self.mark_seen(entry['id']) # the one caller (spec §10): opening a lane is looking at it
|
|
290
478
|
target_file, pid_file = self.slot_file(slot, 'target'), self.slot_file(slot, 'pid')
|
|
291
479
|
if self.slot_target(slot) == entry['id']:
|
|
292
480
|
self.focus_lane(slot)
|
|
@@ -322,6 +510,11 @@ class Sidebar:
|
|
|
322
510
|
self.message = str(error)
|
|
323
511
|
self.dirty = True
|
|
324
512
|
|
|
513
|
+
def request_close(self, entry):
|
|
514
|
+
"""`x` or a click on `×`: ask first (spec §4.6(a)); handle() closes on `y` and cancels on anything else."""
|
|
515
|
+
if entry.get('closable'):
|
|
516
|
+
self.confirming, self.dirty = entry, True
|
|
517
|
+
|
|
325
518
|
def close_lane(self, entry):
|
|
326
519
|
if not entry.get('closable'):
|
|
327
520
|
return
|
|
@@ -354,21 +547,26 @@ class Sidebar:
|
|
|
354
547
|
self.dirty = True
|
|
355
548
|
return
|
|
356
549
|
try:
|
|
357
|
-
|
|
550
|
+
# Routed by the registry SHAPE, never by the id's spelling: an adopted session keeps its `--x-` id
|
|
551
|
+
# but its name authority is registered-sessions.json, not user-sessions/ (DEF-1412).
|
|
552
|
+
if entry.get('registered_session'):
|
|
553
|
+
R.rename(entry['id'], new_name)
|
|
554
|
+
self.message = f'Renamed → {new_name}'
|
|
555
|
+
self.reload(force=True)
|
|
556
|
+
elif entry.get('closable'):
|
|
358
557
|
# Unregistered session
|
|
359
|
-
|
|
360
|
-
for
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
lst[i] = dict(e, name=new_name)
|
|
558
|
+
A.rename_session(self.workspace, entry, new_name)
|
|
559
|
+
for i, e in enumerate(self.unregistered):
|
|
560
|
+
if e['id'] == entry['id']:
|
|
561
|
+
self.unregistered[i] = dict(e, name=new_name)
|
|
364
562
|
self.message = f'Renamed → {new_name}'
|
|
365
563
|
else:
|
|
366
|
-
# Registered lane:
|
|
367
|
-
|
|
564
|
+
# Registered lane: a repositories[i].displayNames override in the board manifest + sync.
|
|
565
|
+
# The lane id never changes, so the cursor, view targets and `lane open <id>` keep working.
|
|
368
566
|
registry_name = entry.get('registry_name') or entry.get('name', '')
|
|
369
|
-
if not
|
|
370
|
-
raise L.LanesError('No
|
|
371
|
-
A.rename_registered(
|
|
567
|
+
if not entry.get('repo'):
|
|
568
|
+
raise L.LanesError('No repository for this lane; edit the workspace manifest directly')
|
|
569
|
+
A.rename_registered(L.manifest_path(self.workspace), entry['repo'], registry_name, new_name)
|
|
372
570
|
# Trigger a registry rebuild
|
|
373
571
|
import subprocess as _sp
|
|
374
572
|
_sp.Popen([os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
@@ -383,8 +581,15 @@ class Sidebar:
|
|
|
383
581
|
"""Begin promotion confirmation for an unregistered session."""
|
|
384
582
|
if entry.get('divider') or entry.get('action'):
|
|
385
583
|
return
|
|
386
|
-
|
|
387
|
-
|
|
584
|
+
# Gated the way `start_registration` is: only an UNREGISTERED, reconnectable tmux session. A repository
|
|
585
|
+
# lane or an adopted (`registered_session`) lane is already registered; promoting it would append a
|
|
586
|
+
# second declaration of the same binding to the repository's terminals.json (DEF-1411).
|
|
587
|
+
if entry.get('registered_session') or entry.get('repo') or entry.get('source'):
|
|
588
|
+
self.message = 'Already a registered lane; nothing to promote'
|
|
589
|
+
self.dirty = True
|
|
590
|
+
return
|
|
591
|
+
if not entry.get('closable') or not R.eligible(entry):
|
|
592
|
+
self.message = 'Only unregistered tmux sessions can be promoted'
|
|
388
593
|
self.dirty = True
|
|
389
594
|
return
|
|
390
595
|
self.promoting = True
|
|
@@ -413,6 +618,40 @@ class Sidebar:
|
|
|
413
618
|
self.message = str(error)
|
|
414
619
|
self.dirty = True
|
|
415
620
|
|
|
621
|
+
def start_registration(self, entry):
|
|
622
|
+
if not entry.get('closable') or not R.eligible(entry):
|
|
623
|
+
return
|
|
624
|
+
choices = R.destinations({'workspaces': self.workspaces}, entry)
|
|
625
|
+
choices.sort(key=lambda w: w['id'] != self.workspace)
|
|
626
|
+
if not choices:
|
|
627
|
+
self.message, self.dirty = 'No workspace uses this session’s container', True
|
|
628
|
+
return
|
|
629
|
+
self.registration = entry
|
|
630
|
+
self.message = ''
|
|
631
|
+
self.armed = False
|
|
632
|
+
self.registration_choices = [{'id': w['id'], 'workspace': w['id'], 'name': w.get('title') or w['id'],
|
|
633
|
+
'action': 'register-workspace'} for w in choices]
|
|
634
|
+
self.filter, self.filtering, self.cursor, self.top, self.dirty = '', False, 0, 0, True
|
|
635
|
+
|
|
636
|
+
def cancel_registration(self):
|
|
637
|
+
self.registration, self.registration_choices = None, []
|
|
638
|
+
self.cursor, self.top, self.dirty = 0, 0, True
|
|
639
|
+
|
|
640
|
+
def register_lane(self, workspace):
|
|
641
|
+
entry = self.registration
|
|
642
|
+
if not entry:
|
|
643
|
+
return
|
|
644
|
+
try:
|
|
645
|
+
lane = R.register(self.workspace, entry, workspace)
|
|
646
|
+
self.unregistered = [e for e in self.unregistered if e['id'] != lane['id']]
|
|
647
|
+
title = next((w.get('title') or w['id'] for w in self.workspaces if w['id'] == workspace), workspace)
|
|
648
|
+
self.cancel_registration()
|
|
649
|
+
self.reload(force=True)
|
|
650
|
+
self.message = 'Added to ' + title
|
|
651
|
+
except (L.LanesError, OSError, ValueError) as error:
|
|
652
|
+
self.message = str(error)
|
|
653
|
+
self.dirty = True
|
|
654
|
+
|
|
416
655
|
def spools(self):
|
|
417
656
|
"""Where an upload request may be spooled, best first. A suffixed view (mac-2, mac-w2) belongs to the machine
|
|
418
657
|
whose base device (mac) `lanes attach` recorded in devices/<device>.json: that machine's ONE helper serves
|
|
@@ -527,7 +766,7 @@ class Sidebar:
|
|
|
527
766
|
self.render_mobile(cols, rows)
|
|
528
767
|
return
|
|
529
768
|
entries = self.visible()
|
|
530
|
-
self.box = rows >= 14
|
|
769
|
+
self.box = rows >= 14 and not self.registration and not self.attach_only
|
|
531
770
|
body = max(1, rows - 3 - (BOX_ROWS if self.box else 0))
|
|
532
771
|
self.box_top = 2 + body # 1-based screen row where the box starts
|
|
533
772
|
self.cursor = max(0, min(self.cursor, len(entries) - 1))
|
|
@@ -538,9 +777,20 @@ class Sidebar:
|
|
|
538
777
|
target = self.current_target()
|
|
539
778
|
others = {slot: self.slot_target(slot) for slot in SLOTS[1:self.viewports]}
|
|
540
779
|
out = ['\033[?25l\033[H']
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
780
|
+
tone, now = self.tone, time.time()
|
|
781
|
+
# Header (spec §4.3): the title, then the agent glyph counts ending at column 35 (column 36 blank), each in
|
|
782
|
+
# its tone — the same glyphs as column 2, so they are the legend. No agents: `{n} lanes`, ink-dim. While the
|
|
783
|
+
# health banner is up the counts are ink-dim too: a frozen status.json is not live.
|
|
784
|
+
tally = self.header_tally()
|
|
785
|
+
right = ' '.join(text for _status, text in tally) or f'{len(self.entries)} lanes'
|
|
786
|
+
right_width = cell_width(right)
|
|
787
|
+
header, used = clip(' Add to workspace' if self.registration else f' {self.title}', max(1, cols - right_width - 2))
|
|
788
|
+
if tally and not self.banner:
|
|
789
|
+
painted = ' '.join(f'{tone[AGENT_GLYPHS[status][2]]}{text}{tone["reset"]}' for status, text in tally)
|
|
790
|
+
else:
|
|
791
|
+
painted = f'{tone["ink-dim"]}{right}{tone["reset"]}'
|
|
792
|
+
# Column 36 is never written, not even with a blank (the budget gate's cell walk, DEF-1417): `\033[K` clears it.
|
|
793
|
+
out.append(f'\033[1m{header}\033[0m{" " * max(1, cols - 1 - used - right_width)}{painted}\033[K\r\n')
|
|
544
794
|
for row in range(body):
|
|
545
795
|
index = self.top + row
|
|
546
796
|
if index >= len(entries):
|
|
@@ -548,15 +798,22 @@ class Sidebar:
|
|
|
548
798
|
continue
|
|
549
799
|
entry = entries[index]
|
|
550
800
|
if entry.get('divider'):
|
|
551
|
-
|
|
801
|
+
section = next((s for s in ATTENTION if s[0] == entry.get('section')), None)
|
|
802
|
+
if section: # `── ◆ Needs you (3) ───`: the glyph in its tone, the words and the rule as before
|
|
803
|
+
glyph, _ascii, role = AGENT_GLYPHS[section[2]]
|
|
804
|
+
label, used_cols = clip(f'── {glyph} {entry["name"]} ', cols - 1)
|
|
805
|
+
label = label.replace(glyph, f'\033[0m{tone[role]}{glyph}\033[0m\033[2m', 1)
|
|
806
|
+
else:
|
|
807
|
+
label, used_cols = clip(f'── {entry["name"]} ', cols - 1)
|
|
552
808
|
out.append(f'\033[2m{label}{"─" * max(0, cols - 1 - used_cols)}\033[0m\033[K\r\n')
|
|
553
809
|
continue
|
|
554
810
|
if entry.get('action'):
|
|
555
811
|
marker = '▌' if index == self.cursor else ' '
|
|
556
|
-
label, _ = clip(marker + '
|
|
812
|
+
label, _ = clip(marker + ' ' + entry['name'], cols - 1)
|
|
557
813
|
out.append(f'\033[32m{label}\033[0m\033[K\r\n')
|
|
558
814
|
continue
|
|
559
815
|
observed = self.status.get(entry['id'], {})
|
|
816
|
+
agent = observed.get('agent') if isinstance(observed.get('agent'), dict) else {}
|
|
560
817
|
state = observed.get('state', 'helper' if entry.get('holder') == 'exec' else 'unknown')
|
|
561
818
|
glyph, glyph_color = GLYPHS.get(state, GLYPHS['unknown'])
|
|
562
819
|
# stale = statusd got no answer about this lane and republished its LAST state: same glyph, dimmed, and a
|
|
@@ -564,40 +821,51 @@ class Sidebar:
|
|
|
564
821
|
tail = ' '
|
|
565
822
|
if observed.get('stale') and glyph != '?':
|
|
566
823
|
glyph_color, tail = '2;' + glyph_color, '\033[2m?'
|
|
824
|
+
elif agent.get('status') in AGENT_GLYPHS:
|
|
825
|
+
glyph_color = '2' # the agent glyph carries the tone; the stream glyph stays readable, dimmed
|
|
826
|
+
# Column 2 (spec §3/§4.2): what the harness is doing, in its tone; ink-dim when stale; blank for a shell.
|
|
827
|
+
agent_cell = ' '
|
|
828
|
+
if agent.get('status') in AGENT_GLYPHS:
|
|
829
|
+
agent_glyph, _ascii, agent_role = AGENT_GLYPHS[agent['status']]
|
|
830
|
+
agent_role = 'ink-dim' if observed.get('stale') else agent_role
|
|
831
|
+
agent_cell = f'{tone[agent_role]}{agent_glyph}{tone["reset"]}'
|
|
567
832
|
is_target, is_cursor = entry['id'] == target, index == self.cursor
|
|
568
833
|
shown = ''.join(mark for slot, mark in zip(SLOTS[1:self.viewports], '²³') if others.get(slot) == entry['id'])
|
|
569
834
|
shown += viewer_mark(self.status.get(entry['id']))
|
|
570
835
|
marker = '▌' if is_cursor else ' '
|
|
571
836
|
icon = entry.get('icon') or '💻'
|
|
572
837
|
icon_pad = ' ' * (3 - cell_width(icon))
|
|
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)
|
|
838
|
+
can_register = entry.get('closable') and R.eligible(entry)
|
|
839
|
+
# 5 cells more than before go to the agent cell (1) and the age (4): the `+`/`×` columns do not move.
|
|
840
|
+
name_width = max(1, cols - (21 if can_register else 17 if entry.get('closable') else 14))
|
|
841
|
+
# Every row says how long (spec principle 7): the agent's `since` when it has one, else the last output.
|
|
842
|
+
since = agent.get('since') if agent.get('status') in AGENT_GLYPHS else observed.get('activity')
|
|
843
|
+
age = age3(int(now - since) if isinstance(since, (int, float)) and since > 0 else None)
|
|
844
|
+
name, name_used = clip(entry.get('name', entry['id']) + shown, name_width)
|
|
585
845
|
color = entry.get('color')
|
|
586
846
|
style = ('1;' if is_target else '') + ('4;' if is_cursor else '')
|
|
587
847
|
fg = f'38;5;{color}' if isinstance(color, int) else '39'
|
|
588
848
|
background = '' # theme-agnostic: the cursor row is marked by ▌ + underline, not a fixed color
|
|
589
849
|
pad = ' ' * max(0, name_width - name_used)
|
|
590
850
|
close = f'\033[{max(1, cols - 2)}G\033[31m×\033[0m' if entry.get('closable') else ''
|
|
591
|
-
|
|
592
|
-
|
|
851
|
+
add = f'\033[{max(1, cols - 6)}G\033[32m+\033[0m' if can_register else ''
|
|
852
|
+
age_role = 'tone-attention' if agent.get('status') in ATTENTION[0][3] else 'ink-dim'
|
|
853
|
+
out.append(f'{background}\033[36m{marker}\033[39m{agent_cell}{icon}{icon_pad}\033[{style}{fg}m{name}\033[22;24;39m{pad} '
|
|
854
|
+
f'{tone[age_role]}{age}{tone["reset"]} '
|
|
855
|
+
f'\033[{glyph_color}m{glyph}\033[0m{background}{tail}\033[0m{add}{close}\033[K\r\n')
|
|
593
856
|
footer_style = '2'
|
|
594
|
-
if self.
|
|
857
|
+
if self.confirming: # spec §4.6(a): the destructive confirm tops the footer ladder, one prompt shape
|
|
858
|
+
footer = f" › close ‘{self.confirming.get('name', '')}’? y/n"
|
|
859
|
+
footer_style = '31'
|
|
860
|
+
elif self.renaming:
|
|
595
861
|
footer = f' Rename: {self.rename_value}█'
|
|
596
862
|
footer_style = '36'
|
|
597
863
|
elif self.promoting:
|
|
598
864
|
name = self.promote_entry.get('name', '') if self.promote_entry else ''
|
|
599
865
|
footer = f' Promote {name!r} to registered? y/n'
|
|
600
866
|
footer_style = '33'
|
|
867
|
+
elif self.registration:
|
|
868
|
+
footer = ' ' + (self.message or 'Choose a workspace · Esc cancels')
|
|
601
869
|
elif self.filtering or self.filter:
|
|
602
870
|
footer = f' /{self.filter}'
|
|
603
871
|
elif self.banner: # above everything but the filter being typed: a frozen picture must never look live
|
|
@@ -607,7 +875,7 @@ class Sidebar:
|
|
|
607
875
|
elif self.message:
|
|
608
876
|
footer = ' ' + self.message
|
|
609
877
|
else:
|
|
610
|
-
footer = ' ⏎ open ·
|
|
878
|
+
footer = ' Enter open · j/k select' if self.attach_only else ' ⏎ open · a add · x close · R rename · + new'
|
|
611
879
|
footer, _ = clip(footer, cols - 1)
|
|
612
880
|
if self.box:
|
|
613
881
|
for line in self.box_lines(cols):
|
|
@@ -631,13 +899,14 @@ class Sidebar:
|
|
|
631
899
|
target = self.current_target()
|
|
632
900
|
out = ['\033[?25l\033[H']
|
|
633
901
|
|
|
634
|
-
def line(value='', style='0', closable=False):
|
|
635
|
-
value, _ = clip(value, max(1, cols - (5 if closable else 1)))
|
|
902
|
+
def line(value='', style='0', closable=False, can_register=False):
|
|
903
|
+
value, _ = clip(value, max(1, cols - (9 if can_register else 5 if closable else 1)))
|
|
636
904
|
close = f'\033[{max(1, cols - 2)}G\033[31m×\033[0m' if closable else ''
|
|
637
|
-
|
|
905
|
+
add = f'\033[{max(1, cols - 6)}G\033[32m+\033[0m' if can_register else ''
|
|
906
|
+
out.append(f'\033[{style}m{value}\033[0m{add}{close}\033[K\r\n')
|
|
638
907
|
|
|
639
908
|
count = sum(not e.get('divider') and not e.get('action') for e in entries)
|
|
640
|
-
line(f' LANES · {count}', '1;36')
|
|
909
|
+
line(' ADD TO WORKSPACE' if self.registration else f' LANES · {count}', '1;36')
|
|
641
910
|
line()
|
|
642
911
|
for index in range(self.top, self.top + body):
|
|
643
912
|
if index >= len(entries):
|
|
@@ -652,37 +921,43 @@ class Sidebar:
|
|
|
652
921
|
if entry.get('divider'):
|
|
653
922
|
line(' ' + entry['name'], '2')
|
|
654
923
|
elif entry.get('action'):
|
|
655
|
-
line(f'{marker}
|
|
924
|
+
line(f'{marker} {entry["name"]}', '1;32')
|
|
656
925
|
else:
|
|
657
926
|
# The viewer dots are the sidebar's rule, not the desktop render's: the phone is the one
|
|
658
927
|
# surface where the operator cannot also see the Studio tab, so a lane a browser views has
|
|
659
928
|
# to say so HERE too (DEF-983).
|
|
660
929
|
line(f'{marker} {entry.get("icon") or "▧"} {entry.get("name", entry["id"])}'
|
|
661
930
|
+ viewer_mark(observed),
|
|
662
|
-
'1;32' if selected or entry.get('action') else '1', entry.get('closable', False)
|
|
931
|
+
'1;32' if selected or entry.get('action') else '1', entry.get('closable', False),
|
|
932
|
+
entry.get('closable') and R.eligible(entry))
|
|
663
933
|
if self.entry_height > 1:
|
|
664
934
|
label = MOBILE_LABELS.get(lane_state, lane_state)
|
|
665
935
|
detail = ('Viewing · ' if selected else '') + label
|
|
666
936
|
if entry.get('action'):
|
|
667
|
-
detail = 'Create a persistent shell'
|
|
937
|
+
detail = {'new-shell': 'Create a persistent shell', 'register-workspace': 'Add this session',
|
|
938
|
+
'cancel-registration': 'Keep unregistered'}.get(entry['action'], '')
|
|
668
939
|
if observed.get('stale'):
|
|
669
940
|
detail += ' · status delayed'
|
|
670
941
|
line(' ' + detail if not entry.get('divider') else '', '2')
|
|
671
942
|
line()
|
|
672
943
|
for _ in range(max(0, rows - (2 + body * self.entry_height + 1))):
|
|
673
944
|
line()
|
|
674
|
-
footer = (
|
|
945
|
+
footer = (f" › close ‘{self.confirming.get('name', '')}’? y/n" if self.confirming else
|
|
946
|
+
' ' + (self.message or 'Choose a workspace · Tap Cancel') if self.registration else
|
|
947
|
+
' /' + self.filter if self.filtering or self.filter else
|
|
675
948
|
' ' + self.banner if self.banner else
|
|
676
949
|
' ' + self.errors[0] if self.errors else
|
|
677
950
|
' ' + self.message if self.message else ' Swipe to scroll · Tap to open')
|
|
678
951
|
footer, _ = clip(footer, max(1, cols - 1))
|
|
679
|
-
out.append(f'\033[
|
|
952
|
+
out.append(f'\033[{"31" if self.confirming else "2"}m{footer}\033[0m\033[K\033[J')
|
|
680
953
|
sys.stdout.write(''.join(out))
|
|
681
954
|
sys.stdout.flush()
|
|
682
955
|
self.dirty = False
|
|
683
956
|
|
|
684
957
|
# ---------------------------------------------------------------- input
|
|
685
958
|
def handle(self, data):
|
|
959
|
+
if isinstance(data, bytes): # the raw read, as run() gets it from the pty
|
|
960
|
+
data = data.decode('utf-8', 'replace')
|
|
686
961
|
# Bracketed paste (a drop, or a real paste) can span several reads: buffer until its end marker.
|
|
687
962
|
if self.paste_buffer is not None:
|
|
688
963
|
self.paste_buffer += data
|
|
@@ -699,7 +974,36 @@ class Sidebar:
|
|
|
699
974
|
entries = self.visible()
|
|
700
975
|
i = 0
|
|
701
976
|
while i < len(data):
|
|
977
|
+
entries = self.visible()
|
|
978
|
+
self.cursor = max(0, min(self.cursor, len(entries) - 1))
|
|
702
979
|
ch = data[i]
|
|
980
|
+
if self.confirming is not None:
|
|
981
|
+
# `y` closes the entry that asked; anything else cancels. A key is consumed by the cancel; an escape
|
|
982
|
+
# sequence (Esc, an arrow, a click) cancels and is then handled as usual. Only a key or a mouse PRESS
|
|
983
|
+
# answers: under ?1000h/?1006h the terminal reports the RELEASE that follows every press (the very
|
|
984
|
+
# release of the `×` click that opened this prompt), and wheel/motion reports are no answer either,
|
|
985
|
+
# so those are consumed and the prompt stays up.
|
|
986
|
+
if ch == '\033' and data[i + 1:].startswith('[<'):
|
|
987
|
+
rest = data[i + 1:]
|
|
988
|
+
end = next((k for k, c in enumerate(rest) if c in 'Mm'), -1)
|
|
989
|
+
if end < 0:
|
|
990
|
+
break # an incomplete report is no answer
|
|
991
|
+
try:
|
|
992
|
+
button = int(rest[2:end].split(';')[0])
|
|
993
|
+
except ValueError:
|
|
994
|
+
button = -1
|
|
995
|
+
if rest[end] == 'm' or (button >= 0 and button & (32 | 64)):
|
|
996
|
+
i += end + 2
|
|
997
|
+
continue
|
|
998
|
+
entry, self.confirming, self.dirty = self.confirming, None, True
|
|
999
|
+
if ch in 'yY':
|
|
1000
|
+
self.close_lane(entry)
|
|
1001
|
+
i += 1
|
|
1002
|
+
continue
|
|
1003
|
+
self.message = 'Cancelled'
|
|
1004
|
+
if ch != '\033':
|
|
1005
|
+
i += 1
|
|
1006
|
+
continue
|
|
703
1007
|
if ch == '\033':
|
|
704
1008
|
rest = data[i + 1:]
|
|
705
1009
|
if rest.startswith('[<'): # SGR mouse
|
|
@@ -722,7 +1026,10 @@ class Sidebar:
|
|
|
722
1026
|
if 0 <= index < len(entries) and self.entry_top <= y < self.box_top:
|
|
723
1027
|
self.cursor = index
|
|
724
1028
|
if entries[index].get('closable') and _x >= max(1, self.size[0] - 4):
|
|
725
|
-
self.
|
|
1029
|
+
self.request_close(entries[index])
|
|
1030
|
+
elif (entries[index].get('closable') and R.eligible(entries[index])
|
|
1031
|
+
and _x >= max(1, self.size[0] - 8)):
|
|
1032
|
+
self.start_registration(entries[index])
|
|
726
1033
|
else:
|
|
727
1034
|
self.open_lane(entries[index])
|
|
728
1035
|
i += end + 2
|
|
@@ -744,6 +1051,8 @@ class Sidebar:
|
|
|
744
1051
|
end = next((k for k, c in enumerate(rest[1:], 1) if '@' <= c <= '~'), len(rest) - 1)
|
|
745
1052
|
i += end + 2
|
|
746
1053
|
else:
|
|
1054
|
+
if self.registration:
|
|
1055
|
+
self.cancel_registration()
|
|
747
1056
|
if self.armed:
|
|
748
1057
|
self.armed = False
|
|
749
1058
|
self.focus_lane()
|
|
@@ -807,12 +1116,14 @@ class Sidebar:
|
|
|
807
1116
|
self.cursor = len(entries) - 1
|
|
808
1117
|
elif ch == '/':
|
|
809
1118
|
self.filtering, self.filter, self.cursor = True, '', 0
|
|
810
|
-
elif ch == 'U':
|
|
1119
|
+
elif ch == 'U' and not self.attach_only:
|
|
811
1120
|
self.armed = True
|
|
812
|
-
elif ch == '+':
|
|
1121
|
+
elif ch == '+' and not self.registration and not self.attach_only:
|
|
813
1122
|
self.new_shell()
|
|
1123
|
+
elif ch == 'a' and entries:
|
|
1124
|
+
self.start_registration(entries[self.cursor])
|
|
814
1125
|
elif ch == 'x' and entries:
|
|
815
|
-
self.
|
|
1126
|
+
self.request_close(entries[min(self.cursor, len(entries) - 1)])
|
|
816
1127
|
elif ch == 'R' and entries:
|
|
817
1128
|
entry = entries[min(self.cursor, len(entries) - 1)]
|
|
818
1129
|
self.start_rename(entry)
|