@vibelord/lanes 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +47 -22
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2459 -164
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +106 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1091 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1140 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +625 -15
  20. package/payload/lanes/docs/DESIGN.md +122 -44
  21. package/payload/lanes/docs/INSTALL.md +157 -17
  22. package/payload/lanes/docs/RUNBOOK.md +653 -81
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +792 -140
  25. package/payload/lanes/install/enable-web.sh +2 -0
  26. package/payload/lanes/install/host-install.sh +163 -7
  27. package/payload/lanes/install/services.py +148 -18
  28. package/payload/lanes/install.sh +68 -7
  29. package/payload/lanes/libexec/agent_state.py +74 -1
  30. package/payload/lanes/libexec/cursor_sync.py +6 -0
  31. package/payload/lanes/libexec/device_sync.py +47 -10
  32. package/payload/lanes/libexec/economy_poll.py +1 -0
  33. package/payload/lanes/libexec/enrold.py +41 -22
  34. package/payload/lanes/libexec/keys.py +54 -23
  35. package/payload/lanes/libexec/lanes_lib.py +620 -22
  36. package/payload/lanes/libexec/members.py +16 -2
  37. package/payload/lanes/libexec/notify.py +104 -24
  38. package/payload/lanes/libexec/registrations.py +216 -18
  39. package/payload/lanes/libexec/session_actions.py +182 -14
  40. package/payload/lanes/libexec/sidebar +11 -3
  41. package/payload/lanes/libexec/sidebar.py +2135 -361
  42. package/payload/lanes/libexec/statusd.py +137 -10
  43. package/payload/lanes/libexec/studio_host.py +8 -1
  44. package/payload/lanes/libexec/tokens.py +121 -30
  45. package/payload/lanes/libexec/tokens.sh +86 -2
  46. package/payload/lanes/libexec/update.py +972 -0
  47. package/payload/lanes/libexec/users.py +614 -0
  48. package/payload/lanes/libexec/view.sh +34 -6
  49. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  50. package/payload/lanes/systemd/lanes-gc.service +1 -1
  51. package/payload/lanes/systemd/lanes-update.service +17 -0
  52. package/payload/lanes/systemd/lanes-update.timer +17 -0
  53. package/payload/lanes/web/README.md +207 -25
  54. package/payload/lanes/web/browser.js +3 -5
  55. package/payload/lanes/web/mobile.css +17 -17
  56. package/payload/lanes/web/mobile.js +128 -76
  57. package/payload/lanes/workspaces.example/README.md +3 -1
  58. package/payload/lanes/zellij/config.kdl +106 -56
  59. package/payload/manifest.json +5 -3
@@ -1,4 +1,3 @@
1
- /* Lanes browser shell; see web/README.md, Code notes */
2
1
  function laneShellMode({userAgent, coarse, width, height} = {}) {
3
2
  if (/iPhone|iPod|Android.*Mobile/i.test(userAgent) || (coarse && (width <= 600 || height <= 500))) return 'phone';
4
3
  return coarse ? 'tablet' : 'desktop';
@@ -11,7 +10,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
11
10
  width: innerWidth, height: innerHeight});
12
11
  const root = document.documentElement;
13
12
  // BEGIN lanes tokens (generated by libexec/tokens.py --js; do not edit by hand)
14
- const LANES_ANSI = {dark: ['#7d8896', '#f28b7d', '#6fd39a', '#e5b84a', '#7fb3f2', '#c9a6ff', '#6fd6d0', '#9ca7b5', '#8a95a3', '#ff9d96', '#8fe0a3', '#f7cf7f', '#a5cbff', '#dcc6ff', '#9ae6e0', '#edf1f5'], light: ['#3b4a42', '#b7322a', '#1b7142', '#8a5a00', '#2456a8', '#7a3fb8', '#0f7b7b', '#56616d', '#5f6d66', '#c2332a', '#1b7a38', '#8f5a00', '#2860c4', '#8a4dc8', '#0f7272', '#17241b']};
13
+ const LANES_ANSI = {dark: ['#8792a0', '#ff7b6e', '#5fdc8a', '#ffc233', '#6fb6ff', '#c58bff', '#3fd9cf', '#a6b1be', '#97a3b1', '#ff9a8e', '#86f0ad', '#ffd866', '#98cbff', '#dcb2ff', '#7deee6', '#f2f5f8'], light: ['#2f3b34', '#c41c1c', '#0d7338', '#a04d00', '#1a56c4', '#8228c4', '#006f73', '#4a5661', '#55625b', '#d0221a', '#0e7a3a', '#a85200', '#1f5fd8', '#9133d0', '#00797c', '#111a14']};
15
14
  // END lanes tokens
16
15
  root.dataset.lanesShell = mode;
17
16
  if (mode !== 'phone') {
@@ -23,7 +22,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
23
22
  root.classList.add('lanes-mobile');
24
23
  if (mode === 'tablet') {
25
24
  root.classList.add('lanes-tablet');
26
- try {localStorage.getItem('zellij:mobile-ui') || localStorage.setItem('zellij:mobile-ui', 'on');} catch (_) {}
25
+ try {localStorage.getItem('zellij:mobile-ui') || localStorage.setItem('zellij:mobile-ui', 'on');} catch {}
27
26
  }
28
27
 
29
28
  const elementFocus = HTMLElement.prototype.focus;
@@ -38,9 +37,9 @@ if (typeof module === 'object') module.exports = {laneShellMode};
38
37
  const theme = localStorage.getItem('zellij:mobile-theme') || 'dark';
39
38
  localStorage.setItem('zellij:mobile-theme', theme);
40
39
  root.dataset.zjTheme = theme;
41
- } catch (_) {}
40
+ } catch {}
42
41
  const icons = {
43
- workspaces: '<rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/>',
42
+ workspaces: [[3, 3], [14, 3], [3, 14], [14, 14]].map(([x, y]) => `<rect x="${x}" y="${y}" width="7" height="7" rx="2"/>`).join(''),
44
43
  lanes: '<path d="M8 5h13M8 12h13M8 19h13"/><path d="M3 5h.01M3 12h.01M3 19h.01"/>',
45
44
  panes: '<rect x="3" y="4" width="18" height="16" rx="3"/><path d="M12 4v16"/>',
46
45
  keyboard: '<rect x="2" y="5" width="20" height="14" rx="3"/><path d="M6 9h.01M10 9h.01M14 9h.01M18 9h.01M6 13h.01M10 13h.01M14 13h.01M18 13h.01M8 16h8"/>',
@@ -49,6 +48,8 @@ if (typeof module === 'object') module.exports = {laneShellMode};
49
48
  arrow: '<path d="m9 5 7 7-7 7"/>',
50
49
  check: '<path d="m5 12 4 4L19 6"/>',
51
50
  search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
51
+ copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5h10"/>',
52
+ paste: '<rect x="5" y="5" width="14" height="16" rx="2"/><path d="M9 3h6v4H9z"/>',
52
53
  };
53
54
  const svg = name => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name]}</svg>`;
54
55
  const make = (tag, cls, text) => {
@@ -61,33 +62,34 @@ if (typeof module === 'object') module.exports = {laneShellMode};
61
62
  const button = (label, icon, action) => {
62
63
  const el = make('button', 'lanes-button');
63
64
  el.type = 'button';
64
- el.setAttribute('aria-label', label);
65
+ aria(el, label);
65
66
  el.innerHTML = svg(icon);
66
67
  el.append(make('span', '', label));
67
68
  el.addEventListener('click', action);
68
69
  return el;
69
70
  };
70
- let shell, heading, subtitle, dock, sheet, list, search, count, lanesButton, typingField, quickRow, quickOn;
71
- let laneFilterBar, laneFilterInput, laneFilterPrev = '';
72
- let api, term, previousCards = '', sheetOpen = false, returnFocus;
71
+ let shell, heading, subtitle, dock, sheet, list, search, count, lanesButton, panesButton, pasteKey, typingField, quickRow, quickOn;
72
+ let laneFilterBar, laneFilterInput, laneFilterPrev = '', sentAt = 0, filterTab, owed = new Set();
73
+ let api, term, previousCards = '', sheetOpen = false, returnFocus, menuWas, hintN = 0, timer;
74
+ const queue = [];
73
75
  let fontSize = 16;
74
76
  try {
75
77
  const stored = Number(localStorage.getItem('lanes:font-size'));
76
78
  if (stored >= 14 && stored <= 24) fontSize = stored;
77
- } catch (_) {}
79
+ } catch {}
78
80
  const state = () => api?.getState();
79
81
  const data = () => state()?.data;
80
82
  const nativeClick = selector => document.querySelector(selector)?.click();
81
83
  const resize = () => window.dispatchEvent(new Event('zellij:rendering-resize'));
84
+ const kbd = () => window.__zjSoftKbdCapture, aria = (el, label) => el.setAttribute('aria-label', label);
82
85
 
83
86
  function viewport() {
84
87
  const vv = window.visualViewport;
85
- root.style.setProperty('--lanes-vh', `${vv?.height ?? innerHeight}px`);
86
- root.style.setProperty('--lanes-vw', `${vv?.width ?? innerWidth}px`);
87
- root.style.setProperty('--lanes-offset-top', `${vv?.offsetTop ?? 0}px`);
88
- root.style.setProperty('--lanes-offset-left', `${vv?.offsetLeft ?? 0}px`);
88
+ const px = (k, v) => root.style.setProperty(`--lanes-${k}`, `${v}px`);
89
+ px('vh', vv?.height ?? innerHeight); px('vw', vv?.width ?? innerWidth);
90
+ px('offset-top', vv?.offsetTop ?? 0); px('offset-left', vv?.offsetLeft ?? 0);
89
91
  const occluded = !!vv && innerHeight - vv.height - vv.offsetTop > 120;
90
- const focused = window.__zjSoftKbdCapture?.isFocused || document.activeElement?.matches('input, textarea');
92
+ const focused = kbd()?.isFocused || document.activeElement?.matches('input, textarea');
91
93
  setKeyboard(occluded && focused);
92
94
  }
93
95
  function setKeyboard(visible) {
@@ -95,10 +97,10 @@ if (typeof module === 'object') module.exports = {laneShellMode};
95
97
  root.classList.toggle('lanes-keyboard', !!visible);
96
98
  resize();
97
99
  }
98
- typingField?.classList.toggle('is-typing', !!window.__zjSoftKbdCapture?.isFocused);
100
+ typingField?.classList.toggle('is-typing', !!kbd()?.isFocused);
99
101
  }
100
102
  function buildTypingField() {
101
- const capture = window.__zjSoftKbdCapture?.element;
103
+ const capture = kbd()?.element;
102
104
  const host = document.getElementById('zj-mobile-capture-host');
103
105
  if (!capture || !host) return null;
104
106
  const field = make('div', 'lanes-terminal-input');
@@ -107,16 +109,23 @@ if (typeof module === 'object') module.exports = {laneShellMode};
107
109
  label.append(make('span', '', 'Type here'));
108
110
  label.setAttribute('aria-hidden', 'true');
109
111
  host.removeAttribute('aria-hidden');
110
- host.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:1;pointer-events:auto;';
112
+ const fill = 'position:absolute;inset:0;width:100%;height:100%;opacity:1;pointer-events:auto;';
113
+ host.style.cssText = fill;
111
114
  capture.removeAttribute('aria-hidden');
112
- capture.setAttribute('aria-label', 'Type in terminal');
115
+ aria(capture, 'Type in terminal');
116
+ for (const a of ['type text', 'autocomplete off', 'autocapitalize none']) capture.setAttribute(...a.split(' '));
113
117
  capture.tabIndex = 0;
114
- capture.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;box-sizing:border-box;font-size:16px;' +
115
- 'border:0;border-radius:12px;padding:0;margin:0;opacity:1;background:transparent;color:transparent;' +
116
- 'caret-color:transparent;outline:none;pointer-events:auto;user-select:text;-webkit-user-select:text;';
118
+ capture.style.cssText = fill + 'box-sizing:border-box;font-size:16px;border:0;border-radius:12px;padding:0;margin:0;' +
119
+ 'background:transparent;color:transparent;caret-color:transparent;outline:none;user-select:text;-webkit-user-select:text;';
117
120
  field.append(label, host);
118
- capture.addEventListener('focus', () => field.classList.add('is-typing'));
119
- capture.addEventListener('blur', () => field.classList.remove('is-typing'));
121
+ capture.addEventListener('beforeinput', ev => {
122
+ const t = ev.data ?? ev.dataTransfer?.getData('text'), r = ev.getTargetRanges?.()[0];
123
+ if (ev.cancelable && t && (ev.inputType == 'insertReplacementText' || ev.inputType == 'insertText' && t.length > 1)) {
124
+ ev.preventDefault();
125
+ send('\x7f'.repeat(r ? r.endOffset - r.startOffset : capture.selectionEnd - capture.selectionStart) + t);
126
+ }
127
+ });
128
+ capture.addEventListener('paste', ev => { ev.preventDefault(); paste(ev.clipboardData.getData('text')); });
120
129
  return field;
121
130
  }
122
131
  function focusTab(tab) {
@@ -153,7 +162,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
153
162
  }
154
163
  function openSheet() {
155
164
  returnFocus = document.activeElement;
156
- window.__zjSoftKbdCapture?.element.blur();
165
+ kbd()?.element.blur();
157
166
  document.activeElement?.blur();
158
167
  sheetOpen = true;
159
168
  sheet.hidden = false;
@@ -188,7 +197,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
188
197
  const active = item.name === activeName;
189
198
  const card = make('button', `lanes-workspace${active ? ' is-current' : ''}`);
190
199
  card.type = 'button';
191
- card.setAttribute('aria-label', `${item.name}${active ? ', current workspace' : ''}`);
200
+ aria(card, `${item.name}${active ? ', current workspace' : ''}`);
192
201
  if (active) card.setAttribute('aria-current', 'true');
193
202
  const monogram = make('span', 'lanes-monogram', Array.from(item.name)[0]?.toUpperCase() || 'L');
194
203
  const copy = make('span', 'lanes-workspace-copy');
@@ -211,22 +220,26 @@ if (typeof module === 'object') module.exports = {laneShellMode};
211
220
  const brand = make('img', 'lanes-brand');
212
221
  brand.src = '/lanes-assets/icon.svg'; brand.alt = 'Lanes';
213
222
  const context = make('button', 'lanes-context');
214
- context.type = 'button'; context.setAttribute('aria-label', 'Choose workspace');
223
+ context.type = 'button'; aria(context, 'Choose workspace');
215
224
  heading = make('strong', '', 'Lanes'); subtitle = make('span', '', 'Connecting…');
216
225
  context.append(heading, subtitle); context.addEventListener('click', openSheet);
217
- const more = button('More options', 'more', () => nativeClick('.zj-hamburger'));
218
- more.classList.add('lanes-more');
219
- header.append(brand, context, more);
220
- dock = make('nav', 'lanes-dock'); dock.setAttribute('aria-label', 'Workspace controls');
226
+ addEventListener('pointerdown', () => { menuWas = document.querySelector('.zj-mobile-menu.zj-open'); }, true);
227
+ const more = button('More options', 'more', () => menuWas ? menuWas = null : nativeClick('.zj-hamburger'));
228
+ const copyKey = button('Copy screen', 'copy', () => clip(c => c.writeText(screen()), () => hint('Copied')));
229
+ pasteKey = button('Paste', 'paste', () => clip(c => c.readText(), t => paste(t) && hint('Pasted')));
230
+ for (const b of [copyKey, pasteKey, more]) b.classList.add('lanes-more');
231
+ for (const b of [copyKey, pasteKey]) b.addEventListener('mousedown', ev => ev.preventDefault());
232
+ header.append(brand, context, copyKey, pasteKey, more);
233
+ dock = make('nav', 'lanes-dock'); aria(dock, 'Workspace controls');
221
234
  const spaces = button('Workspaces', 'workspaces', openSheet);
222
235
  lanesButton = button('Lanes', 'lanes', () => {
223
236
  const picker = pickerTab((currentTab()?.name || '').replace(/ ☰$/, ''));
224
237
  if (picker) focusTab(picker); else openSheet();
225
238
  });
226
- const panes = button('Panes', 'panes', () => nativeClick('.zj-pane'));
239
+ panesButton = button('Panes', 'panes', () => nativeClick('.zj-pane'));
227
240
  typingField = buildTypingField();
228
241
  laneFilterBar = buildLaneFilterBar();
229
- dock.append(spaces, lanesButton, panes, laneFilterBar, typingField);
242
+ dock.append(spaces, lanesButton, panesButton, laneFilterBar, typingField);
230
243
  sheet = make('div', 'lanes-sheet'); sheet.hidden = true;
231
244
  sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-labelledby', 'lanes-sheet-title');
232
245
  const panel = make('section', 'lanes-sheet-panel');
@@ -240,7 +253,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
240
253
  searchWrap.innerHTML = svg('search');
241
254
  search = make('input'); search.type = 'search'; search.placeholder = 'Find a workspace';
242
255
  search.autocomplete = 'off'; search.spellcheck = false;
243
- search.setAttribute('aria-label', 'Find a workspace');
256
+ aria(search, 'Find a workspace');
244
257
  search.addEventListener('input', renderCards); searchWrap.append(search);
245
258
  list = make('div', 'lanes-workspaces');
246
259
  const sessionButton = make('button', 'lanes-session-link', 'Switch session');
@@ -266,65 +279,94 @@ if (typeof module === 'object') module.exports = {laneShellMode};
266
279
  installFontControls();
267
280
  resize();
268
281
  }
269
- function sendToTerminal(bytes) {
270
- try { window.__zjSendControl?.({type: 'Write', bytes}); } catch (_) {}
282
+ function send(...frames) {
283
+ const f = kbd()?.sendFn;
284
+ queue.push(...frames);
285
+ if (f) try {
286
+ while (queue.length) {
287
+ if (queue[0] !== null) { f(queue.shift()); sentAt = Date.now(); }
288
+ else if (Date.now() - sentAt < 60) return arm();
289
+ else queue.shift();
290
+ }
291
+ } catch { queue.length = 0; }
292
+ }
293
+ const paste = t => currentTab()?.name.endsWith(' ☰') ? hint('Open a lane to paste') :
294
+ (send('\x1b[200~' + t.replace(/\x1b+(?=\[201~)/g, '').replace(/\r?\n/g, '\r') + '\x1b[201~'), 1);
295
+ function applyLaneFilter(value) {
296
+ const q = [...new TextDecoder().decode(new TextEncoder().encode(value).filter(b => b > 31 && b != 127))];
297
+ const p = [...laneFilterPrev], s = q.join('');
298
+ if (s === laneFilterPrev) return;
299
+ let i = 0;
300
+ while (i < p.length && p[i] === q[i]) i++;
301
+ laneFilterPrev = s;
302
+ if (!s) { clearLaneFilter(); refresh(); }
303
+ else if (!p.length) send('\x1b', null, '/', s);
304
+ else send('\x7f'.repeat(p.length - i) + q.slice(i).join(''));
305
+ }
306
+ async function clip(f, ok) {
307
+ try { ok(await f(navigator.clipboard)); } catch { hint('Clipboard blocked'); }
271
308
  }
272
- function applyLaneFilter(query) {
273
- const bytes = [...new TextEncoder().encode(query)].filter(b => b >= 0x20 && b !== 0x7f);
274
- sendToTerminal([0x1b, 0x2f, ...bytes]);
309
+ function screen() {
310
+ if (term.hasSelection()) return term.getSelection();
311
+ const b = term.buffer.active, rows = [];
312
+ for (let i = 0; i < term.rows; i++) rows.push(b.getLine(b.viewportY + i)?.translateToString(true));
313
+ return rows.join('\n').trimEnd();
275
314
  }
315
+ function hint(s) { text(subtitle, s); hintN = 8; arm(); }
276
316
  function buildQuickRow() {
277
317
  const row = make('div', 'lanes-quick');
278
318
  row.hidden = true;
279
- for (const [label, bytes, name, hold] of [['⏎', [13], 'Enter'], ['y', [121]], ['n', [110], 0, 1], ['Esc', [27]],
280
- ['^C', [3], 'Control C', 1], ['↑', [27, 91, 65], 'Up'], ['↓', [27, 91, 66], 'Down']]) {
281
- const key = make('button', hold ? 'is-destructive' : '', label);
319
+ for (const [label, key, name, hold] of [['⏎', '\r', 'Enter'], ['y'], ['n', 0, 0, 1], ['Esc', '\x1b'],
320
+ ['^C', '\x03', 'Control C', 1], ['↑', '\x1b[A', 'Up'], ['↓', '\x1b[B', 'Down']]) {
321
+ const b = make('button', hold ? 'is-destructive' : '', label);
282
322
  let down = -1;
283
- key.type = 'button'; if (name) key.setAttribute('aria-label', name);
284
- key.addEventListener('mousedown', ev => ev.preventDefault());
285
- key.addEventListener('pointerdown', ev => { down = ev.timeStamp; });
286
- key.addEventListener('pointerup', ev => {
287
- if (!hold || (down >= 0 && ev.timeStamp - down >= 400)) sendToTerminal(bytes); else text(subtitle, 'Hold to send');
323
+ b.type = 'button'; if (name) aria(b, name);
324
+ b.addEventListener('mousedown', ev => ev.preventDefault());
325
+ b.addEventListener('pointerdown', ev => { down = ev.timeStamp; });
326
+ for (const t of ['pointercancel', 'pointerleave']) b.addEventListener(t, () => { down = -1; });
327
+ b.addEventListener('pointerup', ev => {
328
+ if (down >= 0) !hold || ev.timeStamp - down >= 400 ? send(key || label) : hint('Hold to send');
288
329
  down = -1;
289
330
  });
290
- row.append(key);
331
+ row.append(b);
291
332
  }
292
333
  return row;
293
334
  }
294
335
  function clearLaneFilter() {
295
- sendToTerminal([0x1b]);
296
- laneFilterPrev = '';
297
- if (laneFilterInput) laneFilterInput.value = '';
336
+ owed.add(filterTab);
337
+ laneFilterPrev = laneFilterInput.value = '';
298
338
  }
299
339
  function buildLaneFilterBar() {
300
340
  const bar = make('div', 'lanes-filter-bar');
301
- bar.setAttribute('aria-label', 'Filter lanes');
341
+ aria(bar, 'Filter lanes');
302
342
  bar.hidden = true;
303
343
  bar.innerHTML = svg('search');
304
344
  laneFilterInput = make('input');
305
345
  laneFilterInput.type = 'search'; laneFilterInput.placeholder = 'Filter lanes';
306
346
  laneFilterInput.autocomplete = 'off'; laneFilterInput.spellcheck = false;
307
- laneFilterInput.setAttribute('aria-label', 'Filter lanes in this workspace');
308
- laneFilterInput.addEventListener('input', () => {
309
- const q = laneFilterInput.value;
310
- if (q !== laneFilterPrev) { applyLaneFilter(q); laneFilterPrev = q; }
311
- });
347
+ aria(laneFilterInput, 'Filter lanes in this workspace');
348
+ laneFilterInput.addEventListener('input', () => applyLaneFilter(laneFilterInput.value));
312
349
  laneFilterInput.addEventListener('keydown', ev => {
313
- if (ev.key === 'Escape') { ev.preventDefault(); clearLaneFilter(); laneFilterInput.blur(); }
314
- if (ev.key === 'Enter') { ev.preventDefault(); laneFilterInput.blur(); }
350
+ if (ev.key === 'Escape') { clearLaneFilter(); refresh(); }
351
+ if (/^E(scape|nter)$/.test(ev.key)) { ev.preventDefault(); laneFilterInput.blur(); }
315
352
  });
316
353
  bar.append(laneFilterInput);
317
354
  return bar;
318
355
  }
319
- function updateLaneFilterBar(isPicker) {
320
- if (!laneFilterBar) return;
356
+ function updateLaneFilterBar(tab, isPicker) {
321
357
  laneFilterBar.hidden = !isPicker;
322
- if (!isPicker && laneFilterPrev) clearLaneFilter();
358
+ if (tab !== filterTab) {
359
+ const wait = queue.indexOf(null);
360
+ if (wait >= 0) queue.length = wait;
361
+ if (laneFilterPrev) clearLaneFilter();
362
+ }
363
+ if (owed.delete(tab)) send('\x1b', null);
364
+ if (isPicker) filterTab = tab;
323
365
  }
324
366
  function applyFont() {
325
367
  term.options.fontSize = fontSize;
326
368
  term.options.lineHeight = 1.1;
327
- try {localStorage.setItem('lanes:font-size', String(fontSize));} catch (_) {}
369
+ try {localStorage.setItem('lanes:font-size', String(fontSize));} catch {}
328
370
  text(document.getElementById('lanes-font-value'), `${fontSize} px`);
329
371
  resize();
330
372
  }
@@ -339,17 +381,23 @@ if (typeof module === 'object') module.exports = {laneShellMode};
339
381
  return t;
340
382
  }
341
383
  const applyTheme = () => { if (term) term.options.theme = lanesTheme(); };
342
- function installFontControls() {
384
+ function menuRow(label) {
343
385
  const row = make('div', 'lanes-font-controls');
344
- row.append(make('span', '', 'Text size'));
386
+ row.append(make('span', '', label));
387
+ document.querySelector('.zj-mobile-menu')?.prepend(row);
388
+ return row;
389
+ }
390
+ function installFontControls() {
391
+ const row = menuRow('Text size');
345
392
  const smaller = make('button', '', 'A−'), larger = make('button', '', 'A+');
346
- smaller.setAttribute('aria-label', 'Smaller terminal text');
347
- larger.setAttribute('aria-label', 'Larger terminal text');
393
+ aria(smaller, 'Smaller terminal text');
394
+ aria(larger, 'Larger terminal text');
348
395
  const value = make('output', '', `${fontSize} px`); value.id = 'lanes-font-value';
349
396
  smaller.addEventListener('click', () => {fontSize = Math.max(14, fontSize - 1); applyFont();});
350
397
  larger.addEventListener('click', () => {fontSize = Math.min(24, fontSize + 1); applyFont();});
351
398
  row.append(smaller, value, larger);
352
- document.querySelector('.zj-mobile-menu')?.prepend(row);
399
+ fetch('/enrol/site', {credentials: 'same-origin'}).then(r => r.json())
400
+ .then(({version: v}) => /^\d[\w.+-]{0,31}$/.test(v) && menuRow(`Lanes v${v}`)).catch(() => {});
353
401
  }
354
402
  function refresh() {
355
403
  if (!api || !shell) return;
@@ -360,18 +408,21 @@ if (typeof module === 'object') module.exports = {laneShellMode};
360
408
  const name = tab?.name.replace(/ ☰$/, '') || 'Lanes';
361
409
  const picker = tab?.name.endsWith(' ☰');
362
410
  const pane = data()?.panes.find(p => p.pane_id === data()?.active_pane?.pane_id && !p.is_plugin);
363
- const counts = parseCounts(pickerTitle(name));
411
+ const title = pickerTitle(name), counts = parseCounts(title);
364
412
  const chips = countParts(counts).map(([glyph, n]) => glyph + n).join(' ');
365
413
  text(heading, name);
366
- text(subtitle, picker ? chips || 'Choose a lane to continue' : pane?.title || 'Your workspace');
414
+ if (!hintN) text(subtitle, picker ? chips || 'Choose a lane to continue' : pane?.title || 'Your workspace');
367
415
  lanesButton.setAttribute('aria-pressed', String(!!picker));
416
+ panesButton.hidden = pasteKey.hidden = !!picker;
368
417
  const needs = counts ? counts.approval + counts.input : 0;
369
418
  if (needs) lanesButton.dataset.count = String(needs); else delete lanesButton.dataset.count;
370
- updateLaneFilterBar(picker);
371
- const quick = active && !picker;
419
+ updateLaneFilterBar(tab?.name, picker);
420
+ send();
421
+ const quick = active && (!picker || / · [›>] \w+$/.test(title) && 2);
372
422
  if (quick !== quickOn) {
373
423
  quickOn = quick;
374
424
  quickRow.hidden = !quick;
425
+ for (const key of quickRow.children) key.hidden = quick > 1 && !['⏎', 'y', 'Esc'].includes(key.textContent);
375
426
  root.style.setProperty('--lanes-quick', quick ? '48px' : '0px');
376
427
  resize();
377
428
  }
@@ -379,7 +430,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
379
430
  renderCards();
380
431
  }
381
432
  function connect() {
382
- if (api || !window.__zjMobileUi || !window.term || !window.__zjSoftKbdCapture?.element) return;
433
+ if (api || !window.__zjMobileUi || !window.term || !kbd()?.element) return;
383
434
  if (!window.__zjMobileUi.getState()?.data?.tabs?.length) return;
384
435
  api = window.__zjMobileUi; term = window.term;
385
436
  build(); applyFont(); applyTheme();
@@ -396,7 +447,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
396
447
  const token = document.getElementById('token');
397
448
  if (!token || token.dataset.lanes) return;
398
449
  token.dataset.lanes = 'true';
399
- token.autocomplete = 'current-password'; token.setAttribute('aria-label', 'Login token');
450
+ token.autocomplete = 'current-password'; aria(token, 'Login token');
400
451
  token.closest('.security-modal-content').querySelector('h3').textContent = 'Connect to Lanes';
401
452
  token.closest('.security-modal-content').querySelector('.submit-btn').textContent = 'Connect';
402
453
  }
@@ -412,6 +463,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
412
463
  });
413
464
  document.addEventListener('focusout', () => requestAnimationFrame(viewport));
414
465
  new MutationObserver(() => {styleLogin(); connect();}).observe(document.body, {childList: true});
415
- const awaitingClient = setInterval(() => {connect(); if (api) clearInterval(awaitingClient);}, 200);
416
- styleLogin(); connect();
466
+ function tick() { connect(); send(); if (hintN && !--hintN) refresh(); if (api && !hintN && queue[0] !== null) timer = clearInterval(timer); }
467
+ function arm() { timer ||= setInterval(tick, 200); }
468
+ arm(); styleLogin(); connect();
417
469
  })();
@@ -24,7 +24,9 @@ A lane is whatever the registry says it is: `lanes sync` (every two minutes, rea
24
24
  terminal into a lane with a stable id `<board>--<slug of the terminal name>` (with the repository `prefix`). A
25
25
  `displayNames` entry, which the sidebar `R` key and `lanes rename <lane-id> <name>` write into the repository's block
26
26
  of `workspaces.<board>.json` (first write keeps a `.bak-<UTC>` copy beside it), changes only the shown name, never the
27
- id. Add a terminal to `.vscode/terminals.json` for an editor-defined lane.
27
+ id. The repository can also name a lane itself with `lanes.terminals[<name>].displayName` in its `terminals.json`;
28
+ the manifest's `displayNames` entry wins over it, and a `displayName` another lane on the board already answers to is
29
+ ignored (the lane keeps its title). Add a terminal to `.vscode/terminals.json` for an editor-defined lane.
28
30
  Alternatively, use **+** beside a discovered session to register its existing binding and ID in a workspace
29
31
  using the same container. Sync includes these local registrations; they never run a create command.
30
32
  Repository declarations take precedence when they declare the same binding. Legacy Docker shells cannot be adopted.