@vibelord/lanes 1.0.1 → 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 +37 -7
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +651 -50
- 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 +18 -3
- package/payload/lanes/client/lanes-window +17 -1
- package/payload/lanes/container/env.sh +1 -1
- 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 +310 -26
- package/payload/lanes/container/lane-signal +173 -0
- package/payload/lanes/docs/ARCHITECTURE.md +80 -13
- package/payload/lanes/docs/DESIGN.md +182 -4
- package/payload/lanes/docs/RUNBOOK.md +429 -39
- package/payload/lanes/docs/SHARING.md +1 -1
- package/payload/lanes/holders/exec.sh +2 -5
- package/payload/lanes/holders.lock +3 -0
- 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 +32 -0
- package/payload/lanes/libexec/cursor_sync.py +10 -2
- package/payload/lanes/libexec/economy_poll.py +685 -0
- package/payload/lanes/libexec/guard.py +27 -1
- package/payload/lanes/libexec/lanes_lib.py +285 -38
- package/payload/lanes/libexec/notify.py +317 -0
- package/payload/lanes/libexec/registrations.py +1 -1
- package/payload/lanes/libexec/session_actions.py +7 -8
- package/payload/lanes/libexec/sidebar.py +911 -218
- package/payload/lanes/libexec/statusd.py +238 -69
- package/payload/lanes/libexec/tokens.py +38 -7
- package/payload/lanes/libexec/view.sh +13 -3
- 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 +83 -4
- package/payload/lanes/web/index.html +2 -1
- package/payload/lanes/web/mobile.css +53 -198
- package/payload/lanes/web/mobile.js +87 -55
- package/payload/lanes/zellij/config.kdl +67 -16
- package/payload/manifest.json +4 -3
- package/payload/lanes/libexec/legacy_sessions.py +0 -130
|
@@ -6,11 +6,17 @@ target file and signals the viewport supervisor; the zellij layout is never touc
|
|
|
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
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
|
|
9
10
|
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).
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 `$`.
|
|
14
20
|
Truthful under load: a lane statusd could not observe keeps its last glyph, dimmed, with a `?`; the footer line warns
|
|
15
21
|
when the host is under pressure or statusd has fallen behind (age of $STATE/health, measured with our own clock).
|
|
16
22
|
Run via libexec/sidebar (a loop), so it never leaves its pane empty.
|
|
@@ -49,9 +55,21 @@ HEADER_COUNTS = ('needs_approval', 'needs_input', 'working', 'done') # the hea
|
|
|
49
55
|
SECTION_OF = {status: key for key, _title, _glyph, members in ATTENTION for status in members}
|
|
50
56
|
NEEDS_RANK = {status: rank for rank, status in enumerate(ATTENTION[0][3])}
|
|
51
57
|
INF = float('inf')
|
|
52
|
-
# Tones by role name.
|
|
53
|
-
# user's own palette)
|
|
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__`.
|
|
54
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)?$')
|
|
55
73
|
|
|
56
74
|
|
|
57
75
|
def age3(seconds):
|
|
@@ -67,10 +85,79 @@ def age3(seconds):
|
|
|
67
85
|
return f'{min(99, int(seconds // 86400)):2d}d'
|
|
68
86
|
|
|
69
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
|
+
|
|
70
149
|
# The six words statusd publishes (alignment contract A3) and the helper word for an `exec` lane. There is no
|
|
71
|
-
# seventh: statusd's zmx observer used to publish `idle`, which no other surface knew (DEF-823).
|
|
72
|
-
|
|
73
|
-
|
|
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'}
|
|
74
161
|
# The phone sidebar spells the same six words (plus `helper`) in full. The keys are GLYPHS's keys and the
|
|
75
162
|
# vocabulary gate pins that: this map kept `idle` for a whole release after DEF-823 removed it from GLYPHS,
|
|
76
163
|
# because the gate read GLYPHS alone and the map was a literal buried inside `render_mobile` (DEF-983).
|
|
@@ -79,6 +166,44 @@ MOBILE_LABELS = {'active': 'Active', 'quiet': 'Quiet', 'missing': 'Container off
|
|
|
79
166
|
'unknown': 'Checking status'}
|
|
80
167
|
|
|
81
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
|
+
|
|
82
207
|
def viewer_mark(status):
|
|
83
208
|
"""The mark after a lane's name when other sessions view it: one dot per viewer, at most three."""
|
|
84
209
|
count = int((status or {}).get('viewers') or 0)
|
|
@@ -101,6 +226,8 @@ def mtime(path):
|
|
|
101
226
|
|
|
102
227
|
|
|
103
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)
|
|
104
231
|
width = 0
|
|
105
232
|
for ch in text:
|
|
106
233
|
if unicodedata.combining(ch) or ch in '️':
|
|
@@ -110,6 +237,9 @@ def cell_width(text):
|
|
|
110
237
|
|
|
111
238
|
|
|
112
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)
|
|
113
243
|
out, used = [], 0
|
|
114
244
|
for ch in text:
|
|
115
245
|
w = cell_width(ch)
|
|
@@ -123,14 +253,16 @@ def clip(text, width):
|
|
|
123
253
|
HEALTH_STALE = 30 # seconds; statusd rewrites $STATE/health every cycle, at most ~12 s apart when it slows itself down
|
|
124
254
|
|
|
125
255
|
|
|
126
|
-
def health_banner(health, written, now):
|
|
256
|
+
def health_banner(health, written, now, warning='⚠', sep=' · '):
|
|
127
257
|
"""The footer warning; '' = nothing to say. `health` = L.read_health(), `written` = the file's mtime in seconds
|
|
128
258
|
(0 = no such file: a statusd from before it existed — nothing is known, so nothing is claimed).
|
|
129
259
|
statusd is what freezes on a thrashing host, so the age is measured HERE, with our clock, from the file's mtime.
|
|
130
|
-
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."""
|
|
131
263
|
if os.path.exists(os.path.join(STATE, 'flags', 'sockets-lost')):
|
|
132
264
|
# Short, and first: it explains every "not running" on the screen. The fix is one command on the server.
|
|
133
|
-
return '
|
|
265
|
+
return f'{warning} lane sockets deleted{sep}lanes run; on the server: lanes sockets repair'
|
|
134
266
|
if not written:
|
|
135
267
|
return ''
|
|
136
268
|
parts, age = [], int(now - written)
|
|
@@ -139,14 +271,43 @@ def health_banner(health, written, now):
|
|
|
139
271
|
level = health.get('level', 'ok')
|
|
140
272
|
if level != 'ok':
|
|
141
273
|
parts.append('host under memory pressure' if level == 'critical' else 'host busy')
|
|
142
|
-
return '
|
|
274
|
+
return warning + ' ' + sep.join(parts) if parts else ''
|
|
143
275
|
|
|
144
276
|
|
|
145
277
|
SLOTS = ['main', 'b', 'c']
|
|
146
|
-
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'))
|
|
147
289
|
PASTE_START, PASTE_END = '\033[200~', '\033[201~'
|
|
148
290
|
|
|
149
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
|
+
|
|
150
311
|
class Sidebar:
|
|
151
312
|
# Class-level defaults for the registration state, so a Sidebar built without `__init__` (the
|
|
152
313
|
# alignment gate constructs one with `__new__` and sets only the fields its rule needs) reads the
|
|
@@ -158,11 +319,28 @@ class Sidebar:
|
|
|
158
319
|
confirming = None # the entry `x` / `×` asked to close; `y` closes it, any other key cancels
|
|
159
320
|
seen = {} # read-only default; mark_seen() always assigns a fresh dict
|
|
160
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, ''
|
|
161
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
|
|
162
335
|
|
|
163
336
|
def __init__(self, device, workspace, slot='main', viewports='1'):
|
|
164
337
|
self.device, self.workspace, self.slot = device, workspace, slot
|
|
165
|
-
|
|
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'))
|
|
166
344
|
self.mobile_owner, self.mobile_titles, self.next_mobile_poll = False, {}, 0
|
|
167
345
|
try:
|
|
168
346
|
self.viewports = max(1, min(len(SLOTS), int(viewports)))
|
|
@@ -173,6 +351,7 @@ class Sidebar:
|
|
|
173
351
|
self.pid_file = self.slot_file(slot, 'pid')
|
|
174
352
|
self.registry_path = os.path.join(STATE, 'registry.json')
|
|
175
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)
|
|
176
355
|
self.health_path = os.path.join(STATE, 'health')
|
|
177
356
|
self.health, self.health_stamp, self.banner = {}, None, ''
|
|
178
357
|
self.entries, self.title, self.errors = [], workspace, []
|
|
@@ -184,14 +363,23 @@ class Sidebar:
|
|
|
184
363
|
self.renaming, self.rename_value, self.rename_entry = False, '', None
|
|
185
364
|
self.promoting, self.promote_entry = False, None
|
|
186
365
|
self.confirming = None
|
|
187
|
-
self.
|
|
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)
|
|
188
377
|
# `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
|
|
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`.
|
|
190
380
|
self.seen_path = os.path.join(STATE, 'clients', device + '.seen.json')
|
|
191
|
-
seen = read_json(self.seen_path)
|
|
192
|
-
self.
|
|
193
|
-
if isinstance(seen, dict) else {}
|
|
194
|
-
self.stamp = (0, 0, 0)
|
|
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
|
|
195
383
|
self.size = (0, 0)
|
|
196
384
|
self.dirty = True
|
|
197
385
|
self.message = ''
|
|
@@ -214,25 +402,27 @@ class Sidebar:
|
|
|
214
402
|
# ---------------------------------------------------------------- data
|
|
215
403
|
def reload(self, force=False):
|
|
216
404
|
stamp = (mtime(self.registry_path), mtime(self.status_path),
|
|
217
|
-
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))
|
|
218
407
|
if stamp == self.stamp and not force:
|
|
219
408
|
return
|
|
409
|
+
if force:
|
|
410
|
+
self.row_cache.clear() # `r` and every forced reload repaint every row from scratch (spec §4.11)
|
|
220
411
|
# 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.
|
|
222
|
-
|
|
223
|
-
before = self.last_visible
|
|
224
|
-
cursor_id = before[self.cursor].get('id') if 0 <= self.cursor < len(before) else ''
|
|
412
|
+
# the cursor, so remember which lane it is on and re-find it after the reload.
|
|
413
|
+
cursor_id = self.cursor_id
|
|
225
414
|
if stamp[0] != self.stamp[0] or force:
|
|
226
415
|
registry = read_json(self.registry_path) or {}
|
|
227
416
|
boards = registry.get('workspaces', [])
|
|
228
417
|
self.workspaces = boards
|
|
229
418
|
self.mobile_owner = self.mobile and bool(boards) and boards[0]['id'] == self.workspace
|
|
230
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}
|
|
231
421
|
self.mobile_titles.update({tool['tab']: tool['view'] for tool in registry.get('tools', [])})
|
|
232
422
|
for workspace in registry.get('workspaces', []):
|
|
233
423
|
if workspace.get('id') == self.workspace:
|
|
234
424
|
self.title = workspace.get('title') or self.workspace
|
|
235
|
-
self.entries = list(workspace.get('lanes', []))
|
|
425
|
+
self.entries = list(workspace.get('lanes', []))
|
|
236
426
|
self.errors = workspace.get('errors', [])
|
|
237
427
|
self.attach_only = bool(workspace.get('attach_only'))
|
|
238
428
|
break
|
|
@@ -241,16 +431,95 @@ class Sidebar:
|
|
|
241
431
|
self.status = status.get('lanes', {})
|
|
242
432
|
self.unregistered = (status.get('unregistered') or {}).get(self.workspace, [])
|
|
243
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
|
|
244
451
|
self.stamp = stamp
|
|
245
452
|
self.dirty = True
|
|
246
453
|
# `top` is kept (no reset when the list length changes) and shifted by the rows inserted above the cursor, so
|
|
247
454
|
# the cursor's lane stays on its screen row; render() still clamps both on every draw.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
254
523
|
|
|
255
524
|
def poll_mobile_view(self):
|
|
256
525
|
"""One sidebar observes the phone's web client and the first display of each full-width view.
|
|
@@ -288,10 +557,25 @@ class Sidebar:
|
|
|
288
557
|
stamp = mtime(self.health_path)
|
|
289
558
|
if stamp != self.health_stamp:
|
|
290
559
|
self.health_stamp, self.health = stamp, L.read_health(STATE)
|
|
291
|
-
|
|
560
|
+
marks = self.glyphs['marks']
|
|
561
|
+
banner = health_banner(self.health, stamp / 1e9, time.time(), marks['warning'], f" {marks['sep']} ")
|
|
292
562
|
if banner != self.banner:
|
|
293
563
|
self.banner, self.dirty = banner, True
|
|
294
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
|
+
|
|
295
579
|
def current_target(self):
|
|
296
580
|
try:
|
|
297
581
|
with open(self.target_file) as handle:
|
|
@@ -319,20 +603,10 @@ class Sidebar:
|
|
|
319
603
|
finished before now leaves the Done section; a later one is news again. Persisted per device, bounded to
|
|
320
604
|
the lanes status.json knows, so a restarted sidebar does not resurrect what was already looked at.
|
|
321
605
|
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.
|
|
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."""
|
|
323
608
|
since = self.agent_of(lane_id).get('since')
|
|
324
|
-
|
|
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
|
|
609
|
+
self.seen = L.record_seen(self.seen_path, lane_id, since, self.status, self.seen)
|
|
336
610
|
|
|
337
611
|
def agent_rows(self, lanes):
|
|
338
612
|
"""[(entry, agent status or None, since or None, attention key or None)] in ONE pass over status.json — the
|
|
@@ -354,6 +628,111 @@ class Sidebar:
|
|
|
354
628
|
out.append((entry, status, since, key))
|
|
355
629
|
return out
|
|
356
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
|
+
|
|
357
736
|
def attention(self, entry):
|
|
358
737
|
"""The attention section key of a registry entry, or None (the remainder)."""
|
|
359
738
|
return self.agent_rows([entry])[0][3]
|
|
@@ -375,12 +754,112 @@ class Sidebar:
|
|
|
375
754
|
for _entry, status, _since, key in self.agent_rows(self.entries):
|
|
376
755
|
if status in HEADER_COUNTS and (status != 'done' or key):
|
|
377
756
|
counts[status] = counts.get(status, 0) + 1
|
|
378
|
-
|
|
757
|
+
agent = self.glyphs['agent']
|
|
758
|
+
return [(k, f'{agent[k]}{counts[k]}') for k in HEADER_COUNTS if counts.get(k)]
|
|
379
759
|
|
|
380
760
|
def header_counts(self):
|
|
381
761
|
"""The header's right side, plain: `◆1 ◇2 ▸6 ✓1`, zeros omitted, '' when no lane has one (spec §4.3)."""
|
|
382
762
|
return ' '.join(text for _status, text in self.header_tally())
|
|
383
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
|
+
|
|
384
863
|
def sectioned(self, lanes):
|
|
385
864
|
"""Registry lanes in attention order (spec §4.4): Needs you (by urgency, oldest first), Working (oldest
|
|
386
865
|
first), Done (newest first, unseen only), then the rest in registry order under a workspace header — drawn
|
|
@@ -390,19 +869,21 @@ class Sidebar:
|
|
|
390
869
|
if key is None:
|
|
391
870
|
rest.append(entry)
|
|
392
871
|
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))
|
|
872
|
+
buckets[key].append(((NEEDS_RANK[status], INF if since is None else since), status, entry))
|
|
394
873
|
elif key == 'working':
|
|
395
|
-
buckets[key].append((INF if since is None else since, entry))
|
|
874
|
+
buckets[key].append((INF if since is None else since, status, entry))
|
|
396
875
|
else: # done: newest first
|
|
397
|
-
buckets[key].append((INF if since is None else -since, entry))
|
|
876
|
+
buckets[key].append((INF if since is None else -since, status, entry))
|
|
398
877
|
if not any(buckets.values()):
|
|
399
878
|
return rest
|
|
400
879
|
out = []
|
|
401
880
|
for key, title, _glyph, _members in ATTENTION:
|
|
402
881
|
if buckets[key]:
|
|
403
882
|
bucket = sorted(buckets[key], key=itemgetter(0))
|
|
404
|
-
|
|
405
|
-
out
|
|
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]
|
|
406
887
|
if rest:
|
|
407
888
|
out.append({'id': '', 'divider': True, 'name': f'{self.title} ({len(rest)})', 'section': 'rest'})
|
|
408
889
|
return out + rest
|
|
@@ -411,6 +892,95 @@ class Sidebar:
|
|
|
411
892
|
self.last_visible = entries = self._visible()
|
|
412
893
|
return entries
|
|
413
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
|
+
|
|
414
984
|
def _visible(self):
|
|
415
985
|
if self.registration:
|
|
416
986
|
return self.registration_choices + [{'id': '', 'name': 'Cancel', 'action': 'cancel-registration'}]
|
|
@@ -475,23 +1045,12 @@ class Sidebar:
|
|
|
475
1045
|
return
|
|
476
1046
|
if self.agent_of(entry['id']):
|
|
477
1047
|
self.mark_seen(entry['id']) # the one caller (spec §10): opening a lane is looking at it
|
|
478
|
-
target_file, pid_file = self.slot_file(slot, 'target'), self.slot_file(slot, 'pid')
|
|
479
1048
|
if self.slot_target(slot) == entry['id']:
|
|
480
1049
|
self.focus_lane(slot)
|
|
481
1050
|
self.filter, self.filtering, self.dirty = '', False, True
|
|
482
1051
|
return
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
with open(tmp, 'w') as handle:
|
|
486
|
-
handle.write(entry['id'] + '\n')
|
|
487
|
-
os.replace(tmp, target_file)
|
|
488
|
-
try:
|
|
489
|
-
with open(pid_file) as handle:
|
|
490
|
-
pid = int(handle.read().strip())
|
|
491
|
-
with open(f'/proc/{pid}/cmdline', 'rb') as handle:
|
|
492
|
-
if b'view.sh' in handle.read():
|
|
493
|
-
os.kill(pid, signal.SIGUSR1)
|
|
494
|
-
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']):
|
|
495
1054
|
self.message = 'view not running yet'
|
|
496
1055
|
self.focus_lane(slot)
|
|
497
1056
|
self.filter, self.filtering = '', False
|
|
@@ -548,7 +1107,7 @@ class Sidebar:
|
|
|
548
1107
|
return
|
|
549
1108
|
try:
|
|
550
1109
|
# 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-
|
|
1110
|
+
# but its name authority is registered-sessions.json, not user-sessions/ (DEF-1475).
|
|
552
1111
|
if entry.get('registered_session'):
|
|
553
1112
|
R.rename(entry['id'], new_name)
|
|
554
1113
|
self.message = f'Renamed → {new_name}'
|
|
@@ -583,7 +1142,7 @@ class Sidebar:
|
|
|
583
1142
|
return
|
|
584
1143
|
# Gated the way `start_registration` is: only an UNREGISTERED, reconnectable tmux session. A repository
|
|
585
1144
|
# 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-
|
|
1145
|
+
# second declaration of the same binding to the repository's terminals.json (DEF-1474).
|
|
587
1146
|
if entry.get('registered_session') or entry.get('repo') or entry.get('source'):
|
|
588
1147
|
self.message = 'Already a registered lane; nothing to promote'
|
|
589
1148
|
self.dirty = True
|
|
@@ -631,11 +1190,13 @@ class Sidebar:
|
|
|
631
1190
|
self.armed = False
|
|
632
1191
|
self.registration_choices = [{'id': w['id'], 'workspace': w['id'], 'name': w.get('title') or w['id'],
|
|
633
1192
|
'action': 'register-workspace'} for w in choices]
|
|
634
|
-
self.filter, self.filtering, self.
|
|
1193
|
+
self.filter, self.filtering, self.top, self.dirty = '', False, 0, True
|
|
1194
|
+
self.set_cursor(0)
|
|
635
1195
|
|
|
636
1196
|
def cancel_registration(self):
|
|
637
1197
|
self.registration, self.registration_choices = None, []
|
|
638
|
-
self.
|
|
1198
|
+
self.top, self.dirty = 0, True
|
|
1199
|
+
self.set_cursor(0)
|
|
639
1200
|
|
|
640
1201
|
def register_lane(self, workspace):
|
|
641
1202
|
entry = self.registration
|
|
@@ -677,12 +1238,13 @@ class Sidebar:
|
|
|
677
1238
|
token = re.sub(r'%([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), token[7:])
|
|
678
1239
|
if token.startswith('/') and '\n' not in token:
|
|
679
1240
|
paths.append(token)
|
|
1241
|
+
fail = self.glyphs['marks']['fail'] + ' '
|
|
680
1242
|
if not paths or len(paths) != len(tokens):
|
|
681
|
-
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()
|
|
682
1244
|
return
|
|
683
1245
|
spool = next((s for s in self.spools() if time.time() - mtime(os.path.join(s, 'agent.alive')) / 1e9 <= 12), None)
|
|
684
1246
|
if not spool: # no helper is serving either spool
|
|
685
|
-
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()
|
|
686
1248
|
return
|
|
687
1249
|
request = f'{int(time.time() * 1000)}-{os.getpid()}'
|
|
688
1250
|
# The sidecar names the view that asked — the device whose lane gets the paste (`lanes upload --request`).
|
|
@@ -696,6 +1258,8 @@ class Sidebar:
|
|
|
696
1258
|
self.upload = {'id': request, 'spool': spool, 'names': [os.path.basename(p) for p in paths], 'at': time.time()}
|
|
697
1259
|
|
|
698
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."""
|
|
699
1263
|
if not self.upload:
|
|
700
1264
|
return
|
|
701
1265
|
base = os.path.join(self.upload['spool'], self.upload['id']) # the results land where the request went
|
|
@@ -703,18 +1267,20 @@ class Sidebar:
|
|
|
703
1267
|
if progress_at > self.upload.get('progress_at', 0):
|
|
704
1268
|
progress = read_json(base + '.progress') or {}
|
|
705
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'] + ' '
|
|
706
1272
|
note = None
|
|
707
1273
|
if os.path.exists(base + '.done'):
|
|
708
|
-
note =
|
|
1274
|
+
note = ok + ', '.join(self.upload['names'])[:60]
|
|
709
1275
|
self.focus_lane()
|
|
710
1276
|
elif os.path.exists(base + '.err'):
|
|
711
1277
|
try:
|
|
712
1278
|
with open(base + '.err') as handle:
|
|
713
|
-
note =
|
|
1279
|
+
note = fail + handle.readline().strip()[:70]
|
|
714
1280
|
except OSError:
|
|
715
|
-
note = '
|
|
1281
|
+
note = fail + 'upload failed'
|
|
716
1282
|
elif time.time() - max(self.upload['at'], progress_at) > 120:
|
|
717
|
-
note = '
|
|
1283
|
+
note = fail + 'upload timed out'
|
|
718
1284
|
if note:
|
|
719
1285
|
for suffix in ('.done', '.err', '.sent', '.req', '.dev', '.progress'):
|
|
720
1286
|
try:
|
|
@@ -734,26 +1300,70 @@ class Sidebar:
|
|
|
734
1300
|
except (OSError, subprocess.SubprocessError):
|
|
735
1301
|
break
|
|
736
1302
|
|
|
737
|
-
def
|
|
738
|
-
"""The upload
|
|
739
|
-
|
|
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"]} '
|
|
740
1308
|
if self.upload:
|
|
741
|
-
|
|
742
|
-
|
|
1309
|
+
count = len(self.upload['names'])
|
|
1310
|
+
text, style = f' {g["upload"]} sending {count} file{"s" if count != 1 else ""}', tone['tone-working']
|
|
743
1311
|
if self.upload.get('bytes'):
|
|
744
|
-
|
|
1312
|
+
text += f'{sep}{self.upload["bytes"] / 1048576:.1f} MiB'
|
|
745
1313
|
elif self.armed:
|
|
746
|
-
|
|
1314
|
+
text, style = f' {g["armed"]} DROP FILES NOW{sep}anywhere{sep}Esc', tone['bold'] + tone['tone-done']
|
|
747
1315
|
elif self.upload_note and time.time() - self.upload_note_at < 8:
|
|
748
|
-
|
|
1316
|
+
text = ' ' + self.upload_note
|
|
1317
|
+
style = tone['tone-done'] if self.upload_note.startswith(g['ok']) else tone['tone-urgent']
|
|
1318
|
+
else:
|
|
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}'
|
|
749
1336
|
else:
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
|
757
1367
|
|
|
758
1368
|
# ------------------------------------------------------------ rendering
|
|
759
1369
|
def render(self):
|
|
@@ -765,19 +1375,35 @@ class Sidebar:
|
|
|
765
1375
|
if self.mobile:
|
|
766
1376
|
self.render_mobile(cols, rows)
|
|
767
1377
|
return
|
|
768
|
-
entries = self.
|
|
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.
|
|
769
1385
|
self.box = rows >= 14 and not self.registration and not self.attach_only
|
|
770
|
-
|
|
771
|
-
self.
|
|
772
|
-
|
|
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))
|
|
773
1393
|
if self.cursor < self.top:
|
|
774
1394
|
self.top = self.cursor
|
|
775
1395
|
if self.cursor >= self.top + body:
|
|
776
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
|
|
777
1402
|
target = self.current_target()
|
|
778
1403
|
others = {slot: self.slot_target(slot) for slot in SLOTS[1:self.viewports]}
|
|
779
1404
|
out = ['\033[?25l\033[H']
|
|
780
|
-
tone
|
|
1405
|
+
tone = self.tone
|
|
1406
|
+
marks, reset = self.glyphs['marks'], self.tone['reset']
|
|
781
1407
|
# Header (spec §4.3): the title, then the agent glyph counts ending at column 35 (column 36 blank), each in
|
|
782
1408
|
# its tone — the same glyphs as column 2, so they are the legend. No agents: `{n} lanes`, ink-dim. While the
|
|
783
1409
|
# health banner is up the counts are ink-dim too: a frozen status.json is not live.
|
|
@@ -786,128 +1412,130 @@ class Sidebar:
|
|
|
786
1412
|
right_width = cell_width(right)
|
|
787
1413
|
header, used = clip(' Add to workspace' if self.registration else f' {self.title}', max(1, cols - right_width - 2))
|
|
788
1414
|
if tally and not self.banner:
|
|
789
|
-
painted = ' '.join(f'{tone[AGENT_GLYPHS[status][2]]}{text}{
|
|
1415
|
+
painted = ' '.join(f'{tone[AGENT_GLYPHS[status][2]]}{text}{reset}' for status, text in tally)
|
|
790
1416
|
else:
|
|
791
|
-
painted = f'{tone["ink-dim"]}{right}{
|
|
1417
|
+
painted = f'{tone["ink-dim"]}{right}{reset}'
|
|
792
1418
|
# 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'
|
|
794
|
-
|
|
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):
|
|
795
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
|
|
796
1435
|
if index >= len(entries):
|
|
797
1436
|
out.append('\033[K\r\n')
|
|
798
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
|
|
799
1442
|
entry = entries[index]
|
|
800
1443
|
if entry.get('divider'):
|
|
801
|
-
section =
|
|
802
|
-
|
|
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)
|
|
808
|
-
out.append(f'\033[2m{label}{"─" * max(0, cols - 1 - used_cols)}\033[0m\033[K\r\n')
|
|
1444
|
+
section = entry.get('section')
|
|
1445
|
+
out.append(self.divider_line(entry, cols) + '\033[K\r\n')
|
|
809
1446
|
continue
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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')
|
|
814
1453
|
continue
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
shown = ''.join(mark for slot, mark in zip(SLOTS[1:self.viewports], '²³') if others.get(slot) == entry['id'])
|
|
834
|
-
shown += viewer_mark(self.status.get(entry['id']))
|
|
835
|
-
marker = '▌' if is_cursor else ' '
|
|
836
|
-
icon = entry.get('icon') or '💻'
|
|
837
|
-
icon_pad = ' ' * (3 - cell_width(icon))
|
|
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)
|
|
845
|
-
color = entry.get('color')
|
|
846
|
-
style = ('1;' if is_target else '') + ('4;' if is_cursor else '')
|
|
847
|
-
fg = f'38;5;{color}' if isinstance(color, int) else '39'
|
|
848
|
-
background = '' # theme-agnostic: the cursor row is marked by ▌ + underline, not a fixed color
|
|
849
|
-
pad = ' ' * max(0, name_width - name_used)
|
|
850
|
-
close = f'\033[{max(1, cols - 2)}G\033[31m×\033[0m' if entry.get('closable') else ''
|
|
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')
|
|
856
|
-
footer_style = '2'
|
|
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'
|
|
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']
|
|
860
1472
|
elif self.renaming:
|
|
861
|
-
footer = f' Rename: {self.rename_value}
|
|
862
|
-
|
|
1473
|
+
footer, footer_style = f' {lead} Rename: {self.rename_value}', tone['ink']
|
|
1474
|
+
caret = f'{tone["tone-working"]}{g["caret"]}{reset}'
|
|
863
1475
|
elif self.promoting:
|
|
864
|
-
name = self.promote_entry.get('name', '')
|
|
865
|
-
footer = f
|
|
866
|
-
footer_style = '33'
|
|
1476
|
+
name = (self.promote_entry or {}).get('name', '')
|
|
1477
|
+
footer, footer_style = f" {lead} Register {q0}{name}{q1}? y/n", tone['tone-attention']
|
|
867
1478
|
elif self.registration:
|
|
868
|
-
footer = ' ' + (self.message or 'Choose a workspace
|
|
1479
|
+
footer, footer_style = ' ' + (self.message or f'Choose a workspace{sep}Esc cancels'), tone['ink-dim']
|
|
869
1480
|
elif self.filtering or self.filter:
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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']
|
|
873
1486
|
elif self.errors:
|
|
874
|
-
footer = '
|
|
1487
|
+
footer, footer_style = f' {g["warning"]} registry: ' + self.errors[0], tone['tone-urgent']
|
|
875
1488
|
elif self.message:
|
|
876
|
-
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']
|
|
877
1492
|
else:
|
|
878
|
-
footer = '
|
|
879
|
-
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 ''
|
|
880
1496
|
if self.box:
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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)
|
|
884
1502
|
sys.stdout.write(''.join(out))
|
|
885
1503
|
sys.stdout.flush()
|
|
886
1504
|
self.dirty = False
|
|
887
1505
|
|
|
888
1506
|
def render_mobile(self, cols, rows):
|
|
889
|
-
"""Three terminal rows per lane make the entire row a finger-sized touch target.
|
|
890
|
-
|
|
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()
|
|
891
1510
|
self.box = False # the Mac file-drop helper is not available in a phone browser
|
|
892
1511
|
self.entry_top, self.entry_height = 3, 3 if rows >= 8 else 1
|
|
893
1512
|
body = max(1, (rows - 3) // self.entry_height)
|
|
894
1513
|
self.box_top = self.entry_top + body * self.entry_height
|
|
895
|
-
self.
|
|
896
|
-
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()
|
|
897
1515
|
if self.cursor >= self.top + body:
|
|
898
1516
|
self.top = self.cursor - body + 1
|
|
899
1517
|
target = self.current_target()
|
|
1518
|
+
if self.theme_pending and time.monotonic() >= self.theme_deadline:
|
|
1519
|
+
self.apply_theme(self.variant)
|
|
900
1520
|
out = ['\033[?25l\033[H']
|
|
1521
|
+
tone, marks = self.tone, self.glyphs['marks']
|
|
1522
|
+
reset = tone['reset']
|
|
901
1523
|
|
|
902
|
-
def line(value='', style='
|
|
1524
|
+
def line(value='', style='', closable=False, can_register=False):
|
|
903
1525
|
value, _ = clip(value, max(1, cols - (9 if can_register else 5 if closable else 1)))
|
|
904
|
-
close = f'\033[{max(1, cols - 2)}G
|
|
905
|
-
add = f'\033[{max(1, cols - 6)}G
|
|
906
|
-
out.append(f'
|
|
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')
|
|
907
1529
|
|
|
908
|
-
|
|
909
|
-
|
|
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'])
|
|
910
1537
|
line()
|
|
1538
|
+
now, sep = time.time(), f' {marks["sep"]} ' # every ` · ` is the sep mark: the ASCII frame stays seven-bit
|
|
911
1539
|
for index in range(self.top, self.top + body):
|
|
912
1540
|
if index >= len(entries):
|
|
913
1541
|
for _ in range(self.entry_height):
|
|
@@ -916,40 +1544,74 @@ class Sidebar:
|
|
|
916
1544
|
entry = entries[index]
|
|
917
1545
|
observed = self.status.get(entry['id'], {})
|
|
918
1546
|
lane_state = observed.get('state', 'helper' if entry.get('holder') == 'exec' else 'unknown')
|
|
919
|
-
|
|
920
|
-
|
|
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
|
|
921
1579
|
if entry.get('divider'):
|
|
922
|
-
line(
|
|
923
|
-
|
|
924
|
-
|
|
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 ''))
|
|
925
1595
|
else:
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
if self.entry_height > 1:
|
|
934
|
-
label = MOBILE_LABELS.get(lane_state, lane_state)
|
|
935
|
-
detail = ('Viewing · ' if selected else '') + label
|
|
936
|
-
if entry.get('action'):
|
|
937
|
-
detail = {'new-shell': 'Create a persistent shell', 'register-workspace': 'Add this session',
|
|
938
|
-
'cancel-registration': 'Keep unregistered'}.get(entry['action'], '')
|
|
939
|
-
if observed.get('stale'):
|
|
940
|
-
detail += ' · status delayed'
|
|
941
|
-
line(' ' + detail if not entry.get('divider') else '', '2')
|
|
942
|
-
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'])
|
|
943
1603
|
for _ in range(max(0, rows - (2 + body * self.entry_height + 1))):
|
|
944
1604
|
line()
|
|
945
|
-
|
|
946
|
-
|
|
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
|
|
947
1608
|
' /' + self.filter if self.filtering or self.filter else
|
|
948
1609
|
' ' + self.banner if self.banner else
|
|
949
1610
|
' ' + self.errors[0] if self.errors else
|
|
950
|
-
' ' + self.message if self.message else ' Swipe to scroll
|
|
1611
|
+
' ' + self.message if self.message else f' Swipe to scroll{sep}Tap to open')
|
|
951
1612
|
footer, _ = clip(footer, max(1, cols - 1))
|
|
952
|
-
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
|
|
953
1615
|
sys.stdout.write(''.join(out))
|
|
954
1616
|
sys.stdout.flush()
|
|
955
1617
|
self.dirty = False
|
|
@@ -958,6 +1620,9 @@ class Sidebar:
|
|
|
958
1620
|
def handle(self, data):
|
|
959
1621
|
if isinstance(data, bytes): # the raw read, as run() gets it from the pty
|
|
960
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)
|
|
961
1626
|
# Bracketed paste (a drop, or a real paste) can span several reads: buffer until its end marker.
|
|
962
1627
|
if self.paste_buffer is not None:
|
|
963
1628
|
self.paste_buffer += data
|
|
@@ -971,11 +1636,17 @@ class Sidebar:
|
|
|
971
1636
|
self.dropped(pasted)
|
|
972
1637
|
self.dirty = True
|
|
973
1638
|
data += tail
|
|
974
|
-
entries = self.
|
|
1639
|
+
entries = self.follow()
|
|
975
1640
|
i = 0
|
|
976
1641
|
while i < len(data):
|
|
977
|
-
|
|
978
|
-
|
|
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)
|
|
979
1650
|
ch = data[i]
|
|
980
1651
|
if self.confirming is not None:
|
|
981
1652
|
# `y` closes the entry that asked; anything else cancels. A key is consumed by the cancel; an escape
|
|
@@ -1025,7 +1696,9 @@ class Sidebar:
|
|
|
1025
1696
|
index = self.top + (y - self.entry_top) // self.entry_height
|
|
1026
1697
|
if 0 <= index < len(entries) and self.entry_top <= y < self.box_top:
|
|
1027
1698
|
self.cursor = index
|
|
1028
|
-
if
|
|
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):
|
|
1029
1702
|
self.request_close(entries[index])
|
|
1030
1703
|
elif (entries[index].get('closable') and R.eligible(entries[index])
|
|
1031
1704
|
and _x >= max(1, self.size[0] - 8)):
|
|
@@ -1043,6 +1716,10 @@ class Sidebar:
|
|
|
1043
1716
|
if entries:
|
|
1044
1717
|
self.open_lane(entries[self.cursor])
|
|
1045
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
|
|
1046
1723
|
elif rest[:2] == '[5':
|
|
1047
1724
|
self.cursor -= 10; i += 4
|
|
1048
1725
|
elif rest[:2] == '[6':
|
|
@@ -1090,13 +1767,15 @@ class Sidebar:
|
|
|
1090
1767
|
self.filter = self.filter[:-1]
|
|
1091
1768
|
elif ch.isprintable():
|
|
1092
1769
|
self.filter += ch
|
|
1093
|
-
self.cursor
|
|
1770
|
+
self.set_cursor(0) # the top match of the filtered list, not the lane the cursor was on
|
|
1094
1771
|
elif ch in '\r\n ':
|
|
1095
1772
|
if entries:
|
|
1096
1773
|
self.open_lane(entries[self.cursor])
|
|
1097
1774
|
elif ch in '23' and self.viewports > 1:
|
|
1098
1775
|
if entries:
|
|
1099
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)
|
|
1100
1779
|
elif ch in 'JK': # open the next / previous lane relative to the one being shown
|
|
1101
1780
|
lanes = [e for e in entries if not e.get('divider')]
|
|
1102
1781
|
if lanes:
|
|
@@ -1115,7 +1794,8 @@ class Sidebar:
|
|
|
1115
1794
|
elif ch == 'G':
|
|
1116
1795
|
self.cursor = len(entries) - 1
|
|
1117
1796
|
elif ch == '/':
|
|
1118
|
-
self.filtering, self.filter
|
|
1797
|
+
self.filtering, self.filter = True, ''
|
|
1798
|
+
self.set_cursor(0)
|
|
1119
1799
|
elif ch == 'U' and not self.attach_only:
|
|
1120
1800
|
self.armed = True
|
|
1121
1801
|
elif ch == '+' and not self.registration and not self.attach_only:
|
|
@@ -1132,15 +1812,26 @@ class Sidebar:
|
|
|
1132
1812
|
self.start_promote(entry)
|
|
1133
1813
|
elif ch == 'r':
|
|
1134
1814
|
self.reload(force=True)
|
|
1815
|
+
elif ch == '?':
|
|
1816
|
+
self.showing_keys = True
|
|
1135
1817
|
self.dirty = True
|
|
1136
1818
|
i += 1
|
|
1137
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
|
+
|
|
1138
1829
|
def run(self):
|
|
1139
1830
|
fd = sys.stdin.fileno()
|
|
1140
1831
|
saved = termios.tcgetattr(fd)
|
|
1141
1832
|
try:
|
|
1142
1833
|
tty.setraw(fd)
|
|
1143
|
-
sys.stdout.write(
|
|
1834
|
+
sys.stdout.write(self.start_bytes())
|
|
1144
1835
|
self.reload(force=True)
|
|
1145
1836
|
target = self.current_target()
|
|
1146
1837
|
for index, entry in enumerate(self.visible()):
|
|
@@ -1158,11 +1849,13 @@ class Sidebar:
|
|
|
1158
1849
|
last_size, self.dirty = size, True
|
|
1159
1850
|
self.poll_upload()
|
|
1160
1851
|
self.poll_health()
|
|
1161
|
-
|
|
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):
|
|
1162
1855
|
self.dirty = True
|
|
1163
1856
|
if self.dirty:
|
|
1164
1857
|
self.render()
|
|
1165
|
-
ready, _, _ = select.select([fd], [], [],
|
|
1858
|
+
ready, _, _ = select.select([fd], [], [], self.tick_timeout())
|
|
1166
1859
|
if ready:
|
|
1167
1860
|
data = os.read(fd, 4096)
|
|
1168
1861
|
if not data:
|