@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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +37 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +651 -50
  5. package/payload/lanes/client/ghostty/common.conf +3 -0
  6. package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
  7. package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
  8. package/payload/lanes/client/ghostty/omarchy.conf +3 -2
  9. package/payload/lanes/client/lanes-launch +18 -3
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +1 -1
  12. package/payload/lanes/container/hooks/claude-settings.json +10 -0
  13. package/payload/lanes/container/hooks/codex-hooks.json +22 -0
  14. package/payload/lanes/container/lane +310 -26
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +80 -13
  17. package/payload/lanes/docs/DESIGN.md +182 -4
  18. package/payload/lanes/docs/RUNBOOK.md +429 -39
  19. package/payload/lanes/docs/SHARING.md +1 -1
  20. package/payload/lanes/holders/exec.sh +2 -5
  21. package/payload/lanes/holders.lock +3 -0
  22. package/payload/lanes/install/host-install.sh +10 -1
  23. package/payload/lanes/install/services.py +30 -3
  24. package/payload/lanes/install.sh +108 -3
  25. package/payload/lanes/libexec/agent_state.py +32 -0
  26. package/payload/lanes/libexec/cursor_sync.py +10 -2
  27. package/payload/lanes/libexec/economy_poll.py +685 -0
  28. package/payload/lanes/libexec/guard.py +27 -1
  29. package/payload/lanes/libexec/lanes_lib.py +285 -38
  30. package/payload/lanes/libexec/notify.py +317 -0
  31. package/payload/lanes/libexec/registrations.py +1 -1
  32. package/payload/lanes/libexec/session_actions.py +7 -8
  33. package/payload/lanes/libexec/sidebar.py +911 -218
  34. package/payload/lanes/libexec/statusd.py +238 -69
  35. package/payload/lanes/libexec/tokens.py +38 -7
  36. package/payload/lanes/libexec/view.sh +13 -3
  37. package/payload/lanes/systemd/lanes-economy.service +20 -0
  38. package/payload/lanes/systemd/lanes-notify.service +19 -0
  39. package/payload/lanes/systemd/lanes-sync.service +3 -0
  40. package/payload/lanes/web/README.md +83 -4
  41. package/payload/lanes/web/index.html +2 -1
  42. package/payload/lanes/web/mobile.css +53 -198
  43. package/payload/lanes/web/mobile.js +87 -55
  44. package/payload/lanes/zellij/config.kdl +67 -16
  45. package/payload/manifest.json +4 -3
  46. 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
- Upload box (bottom): click it or press U (Ctrl-Alt-u from anywhere), then DROP FILES anywhere on the window. A
12
- terminal delivers a drop as a paste of the LOCAL path into the focused pane, so the box must have focus first; the
13
- file itself is then sent by the upload helper on your machine (it only reads folders you allowed there).
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. Until Task 22 resolves LANES_COLOR/LANES_THEME per device this is the 16-colour table (the
53
- # user's own palette); new code paints through role names so Task 22 swaps the table, not the call sites.
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
- GLYPHS = {'active': ('●', '32'), 'quiet': ('○', '2'), 'missing': ('✖', '31'), 'absent': ('–', '2'),
73
- 'dead': ('!', '33'), 'helper': ('·', '2'), 'unknown': ('?', '2')}
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 '⚠ lane sockets deleted — lanes run; on the server: lanes sockets repair'
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 '⚠ ' + ' · '.join(parts) if parts else ''
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
- self.mobile = bool((read_json(os.path.join(STATE, 'devices', device + '.json')) or {}).get('mobile'))
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.tone = TONE16
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 only by mark_seen().
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.seen = {k: v for k, v in seen.items() if isinstance(k, str) and isinstance(v, (int, float))} \
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. `last_visible` is the list the
222
- # cursor indexes (render() and handle() compute it before they use the cursor) — no second sectioning pass.
223
- before = self.last_visible
224
- cursor_id = before[self.cursor].get('id') if 0 <= self.cursor < len(before) else ''
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', [])) + list(workspace.get('extras', []))
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
- after = self.visible() # also refreshes last_visible for the next reload
249
- if cursor_id:
250
- index = next((i for i, e in enumerate(after) if e.get('id') == cursor_id), None)
251
- if index is not None and index != self.cursor:
252
- self.top = max(0, self.top + index - self.cursor)
253
- self.cursor = index
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
- banner = health_banner(self.health, stamp / 1e9, time.time())
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
- disk = read_json(self.seen_path)
325
- seen = {k: v for k, v in (disk.items() if isinstance(disk, dict) else ())
326
- if isinstance(k, str) and isinstance(v, (int, float))}
327
- for key, value in self.seen.items():
328
- seen[key] = max(value, seen.get(key, value))
329
- seen = {k: v for k, v in seen.items() if k in self.status}
330
- seen[lane_id] = max(time.time(), since if isinstance(since, (int, float)) else 0)
331
- self.seen = seen
332
- try:
333
- L.atomic_write(Path(self.seen_path), json.dumps(seen, sort_keys=True) + '\n')
334
- except OSError:
335
- pass # the queue still works for this process; only a restart would forget
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
- return [(k, f'{AGENT_GLYPHS[k][0]}{counts[k]}') for k in HEADER_COUNTS if counts.get(k)]
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
- out.append({'id': '', 'divider': True, 'name': f'{title} ({len(bucket)})', 'section': key})
405
- out += [entry for _rank, entry in bucket]
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
- os.makedirs(self.view_dir, exist_ok=True)
484
- tmp = target_file + f'.{os.getpid()}.tmp'
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-1412).
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-1411).
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.cursor, self.top, self.dirty = '', False, 0, 0, True
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.cursor, self.top, self.dirty = 0, 0, True
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 = '✓ ' + ', '.join(self.upload['names'])[:60]
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 = '✖ ' + handle.readline().strip()[:70]
1279
+ note = fail + handle.readline().strip()[:70]
714
1280
  except OSError:
715
- note = '✖ upload failed'
1281
+ note = fail + 'upload failed'
716
1282
  elif time.time() - max(self.upload['at'], progress_at) > 120:
717
- note = '✖ upload timed out'
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 box_lines(self, cols):
738
- """The upload box: BOX_ROWS lines, last rows of the sidebar."""
739
- inner = max(4, cols - 2)
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
- spin = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[int(time.time() * 8) % 10]
742
- body, style = [f'{spin} sending {len(self.upload["names"])} file(s)…', ', '.join(self.upload['names'])], '33'
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
- body[1] = f'{self.upload["bytes"] / 1048576:.1f} MiB · ' + body[1]
1312
+ text += f'{sep}{self.upload["bytes"] / 1048576:.1f} MiB'
745
1313
  elif self.armed:
746
- body, style = ['⬇ DROP FILES NOW', 'anywhere on this window · Esc'], '1;32'
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
- body, style = [self.upload_note, f'→ {self.workspace}/uploads'], '36'
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
- body, style = ['⬆ uploads — click, then drop', f'→ {self.workspace}/uploads (U)'], '2'
751
- lines = [f'\033[{style}m┌{"─" * inner}┐\033[0m']
752
- for text in body[:BOX_ROWS - 2]:
753
- text, used = clip(' ' + text, inner)
754
- lines.append(f'\033[{style}m│\033[0m\033[{style}m{text}{" " * (inner - used)}\033[0m\033[{style}m│\033[0m')
755
- lines.append(f'\033[{style}m└{"─" * inner}┘\033[0m')
756
- return lines
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.visible()
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
- body = max(1, rows - 3 - (BOX_ROWS if self.box else 0))
771
- self.box_top = 2 + body # 1-based screen row where the box starts
772
- self.cursor = max(0, min(self.cursor, len(entries) - 1))
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, now = self.tone, time.time()
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}{tone["reset"]}' for status, text in tally)
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}{tone["reset"]}'
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'\033[1m{header}\033[0m{" " * max(1, cols - 1 - used - right_width)}{painted}\033[K\r\n')
794
- for row in range(body):
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 = next((s for s in ATTENTION if s[0] == entry.get('section')), None)
802
- if section: # `── ◆ Needs you (3) ───`: the glyph in its tone, the words and the rule as before
803
- glyph, _ascii, role = AGENT_GLYPHS[section[2]]
804
- label, used_cols = clip(f'── {glyph} {entry["name"]} ', cols - 1)
805
- label = label.replace(glyph, f'\033[0m{tone[role]}{glyph}\033[0m\033[2m', 1)
806
- else:
807
- label, used_cols = clip(f'── {entry["name"]} ', cols - 1)
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
- if entry.get('action'):
811
- marker = '▌' if index == self.cursor else ' '
812
- label, _ = clip(marker + ' ' + entry['name'], cols - 1)
813
- out.append(f'\033[32m{label}\033[0m\033[K\r\n')
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
- observed = self.status.get(entry['id'], {})
816
- agent = observed.get('agent') if isinstance(observed.get('agent'), dict) else {}
817
- state = observed.get('state', 'helper' if entry.get('holder') == 'exec' else 'unknown')
818
- glyph, glyph_color = GLYPHS.get(state, GLYPHS['unknown'])
819
- # stale = statusd got no answer about this lane and republished its LAST state: same glyph, dimmed, and a
820
- # '?' in the spare cell after it (no cell is added: rows, clicks and the upload box stay where they are).
821
- tail = ' '
822
- if observed.get('stale') and glyph != '?':
823
- glyph_color, tail = '2;' + glyph_color, '\033[2m?'
824
- elif agent.get('status') in AGENT_GLYPHS:
825
- glyph_color = '2' # the agent glyph carries the tone; the stream glyph stays readable, dimmed
826
- # Column 2 (spec §3/§4.2): what the harness is doing, in its tone; ink-dim when stale; blank for a shell.
827
- agent_cell = ' '
828
- if agent.get('status') in AGENT_GLYPHS:
829
- agent_glyph, _ascii, agent_role = AGENT_GLYPHS[agent['status']]
830
- agent_role = 'ink-dim' if observed.get('stale') else agent_role
831
- agent_cell = f'{tone[agent_role]}{agent_glyph}{tone["reset"]}'
832
- is_target, is_cursor = entry['id'] == target, index == self.cursor
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
- footer_style = '36'
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', '') if self.promote_entry else ''
865
- footer = f' Promote {name!r} to registered? y/n'
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 · Esc cancels')
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
- footer = f' /{self.filter}'
871
- elif self.banner: # above everything but the filter being typed: a frozen picture must never look live
872
- footer, footer_style = ' ' + self.banner, '33'
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 = ' ⚠ registry: ' + self.errors[0]
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 = ' Enter open · j/k select' if self.attach_only else ' ⏎ open · a add · x close · R rename · + new'
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
- for line in self.box_lines(cols):
882
- out.append(line + '\033[K\r\n')
883
- out.append(f'\033[2m{"─" * cols}\033[0m\r\n\033[{footer_style}m{footer}\033[0m\033[K\033[J')
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
- entries = self.visible()
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.cursor = max(0, min(self.cursor, len(entries) - 1))
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='0', closable=False, can_register=False):
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\033[31m×\033[0m' if closable else ''
905
- add = f'\033[{max(1, cols - 6)}G\033[32m+\033[0m' if can_register else ''
906
- out.append(f'\033[{style}m{value}\033[0m{add}{close}\033[K\r\n')
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
- count = sum(not e.get('divider') and not e.get('action') for e in entries)
909
- line(' ADD TO WORKSPACE' if self.registration else f' LANES · {count}', '1;36')
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
- selected = entry['id'] == target
920
- marker = '›' if index == self.cursor else ' '
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(' ' + entry['name'], '2')
923
- elif entry.get('action'):
924
- line(f'{marker} {entry["name"]}', '1;32')
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
- # The viewer dots are the sidebar's rule, not the desktop render's: the phone is the one
927
- # surface where the operator cannot also see the Studio tab, so a lane a browser views has
928
- # to say so HERE too (DEF-983).
929
- line(f'{marker} {entry.get("icon") or "▧"} {entry.get("name", entry["id"])}'
930
- + viewer_mark(observed),
931
- '1;32' if selected or entry.get('action') else '1', entry.get('closable', False),
932
- entry.get('closable') and R.eligible(entry))
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
- footer = (f" › close ‘{self.confirming.get('name', '')}’? y/n" if self.confirming else
946
- ' ' + (self.message or 'Choose a workspace · Tap Cancel') if self.registration else
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 · Tap to open')
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'\033[{"31" if self.confirming else "2"}m{footer}\033[0m\033[K\033[J')
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.visible()
1639
+ entries = self.follow()
975
1640
  i = 0
976
1641
  while i < len(data):
977
- entries = self.visible()
978
- self.cursor = max(0, min(self.cursor, len(entries) - 1))
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 entries[index].get('closable') and _x >= max(1, self.size[0] - 4):
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 = 0
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, self.cursor = True, '', 0
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('\033[?1049h\033[?1000h\033[?1006h\033[?2004h\033[2J')
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
- if self.upload or self.armed or (self.upload_note and time.time() - self.upload_note_at < 9):
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], [], [], 0.15 if self.upload else 1.0)
1858
+ ready, _, _ = select.select([fd], [], [], self.tick_timeout())
1166
1859
  if ready:
1167
1860
  data = os.read(fd, 4096)
1168
1861
  if not data: