@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
@@ -1,5 +1,4 @@
1
- /* Lanes browser shell over the pinned Zellij 0.45.1 client's window hooks; auth, input, rendering and transport
2
- * stay upstream. LNS-31: phone (today's rule or a coarse landscape phone), tablet (other touch), desktop. */
1
+ /* Lanes browser shell; see web/README.md, Code notes */
3
2
  function laneShellMode({userAgent, coarse, width, height} = {}) {
4
3
  if (/iPhone|iPod|Android.*Mobile/i.test(userAgent) || (coarse && (width <= 600 || height <= 500))) return 'phone';
5
4
  return coarse ? 'tablet' : 'desktop';
@@ -11,8 +10,11 @@ if (typeof module === 'object') module.exports = {laneShellMode};
11
10
  const mode = laneShellMode({userAgent: navigator.userAgent, coarse: matchMedia('(pointer: coarse)').matches,
12
11
  width: innerWidth, height: innerHeight});
13
12
  const root = document.documentElement;
14
- root.dataset.lanesShell = mode; // web/browser.js: the desktop bar and the tablet's full rendering
15
- if (mode !== 'phone') { // laptop/tablet extras: a phone never fetches them
13
+ // 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']};
15
+ // END lanes tokens
16
+ root.dataset.lanesShell = mode;
17
+ if (mode !== 'phone') {
16
18
  const add = (tag, props) => document.head.append(Object.assign(document.createElement(tag), props));
17
19
  add('link', {rel: 'stylesheet', href: '/lanes-assets/browser.css'});
18
20
  add('script', {src: '/lanes-assets/browser.js'});
@@ -20,14 +22,10 @@ if (typeof module === 'object') module.exports = {laneShellMode};
20
22
  if (mode === 'desktop') return;
21
23
  root.classList.add('lanes-mobile');
22
24
  if (mode === 'tablet') {
23
- // iPad Safari's UA is desktop: ask for the mobile UI (a stored 'off' stays).
24
25
  root.classList.add('lanes-tablet');
25
26
  try {localStorage.getItem('zellij:mobile-ui') || localStorage.setItem('zellij:mobile-ui', 'on');} catch (_) {}
26
27
  }
27
28
 
28
- // Only a browser's native tap/focus on an input may summon the keyboard. Zellij
29
- // otherwise refocuses a hidden input on touchstart, touchend, click and server
30
- // SetSoftKeyboard messages. Install before its first terminal or login modal.
31
29
  const elementFocus = HTMLElement.prototype.focus;
32
30
  HTMLElement.prototype.focus = function(...args) {
33
31
  const textInput = this instanceof HTMLInputElement &&
@@ -35,11 +33,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
35
33
  if (textInput || this instanceof HTMLTextAreaElement || this.isContentEditable) return;
36
34
  return elementFocus.apply(this, args);
37
35
  };
38
- Object.defineProperty(window, '__zjSoftKbdEnabled', {
39
- configurable: false,
40
- get: () => false,
41
- set: () => {},
42
- });
36
+ Object.defineProperty(window, '__zjSoftKbdEnabled', {configurable: false, get: () => false, set: () => {}});
43
37
  try {
44
38
  const theme = localStorage.getItem('zellij:mobile-theme') || 'dark';
45
39
  localStorage.setItem('zellij:mobile-theme', theme);
@@ -73,7 +67,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
73
67
  el.addEventListener('click', action);
74
68
  return el;
75
69
  };
76
- let shell, heading, subtitle, dock, sheet, list, search, count, lanesButton, typingField;
70
+ let shell, heading, subtitle, dock, sheet, list, search, count, lanesButton, typingField, quickRow, quickOn;
77
71
  let laneFilterBar, laneFilterInput, laneFilterPrev = '';
78
72
  let api, term, previousCards = '', sheetOpen = false, returnFocus;
79
73
  let fontSize = 16;
@@ -92,9 +86,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
92
86
  root.style.setProperty('--lanes-vw', `${vv?.width ?? innerWidth}px`);
93
87
  root.style.setProperty('--lanes-offset-top', `${vv?.offsetTop ?? 0}px`);
94
88
  root.style.setProperty('--lanes-offset-left', `${vv?.offsetLeft ?? 0}px`);
95
- // Browser toolbars alone must not put the UI into keyboard mode.
96
89
  const occluded = !!vv && innerHeight - vv.height - vv.offsetTop > 120;
97
- // Zellij's keyboard input lives in a closed shadow root, so activeElement is its host.
98
90
  const focused = window.__zjSoftKbdCapture?.isFocused || document.activeElement?.matches('input, textarea');
99
91
  setKeyboard(occluded && focused);
100
92
  }
@@ -114,19 +106,14 @@ if (typeof module === 'object') module.exports = {laneShellMode};
114
106
  label.innerHTML = svg('keyboard');
115
107
  label.append(make('span', '', 'Type here'));
116
108
  label.setAttribute('aria-hidden', 'true');
117
- // Keep the original input and its event handlers inside its closed shadow
118
- // root (including its protection from password-manager extensions). Make it
119
- // a real, tappable field instead of a 1px invisible focus target. Keystrokes
120
- // appear in the terminal; its internal padding/history stays invisible.
121
109
  host.removeAttribute('aria-hidden');
122
110
  host.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:1;pointer-events:auto;';
123
111
  capture.removeAttribute('aria-hidden');
124
112
  capture.setAttribute('aria-label', 'Type in terminal');
125
113
  capture.tabIndex = 0;
126
- capture.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;box-sizing:border-box;' +
127
- 'font-size:16px;border:0;border-radius:12px;padding:0;margin:0;opacity:1;' +
128
- 'background:transparent;color:transparent;caret-color:transparent;outline:none;' +
129
- 'pointer-events:auto;user-select:text;-webkit-user-select:text;';
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;';
130
117
  field.append(label, host);
131
118
  capture.addEventListener('focus', () => field.classList.add('is-typing'));
132
119
  capture.addEventListener('blur', () => field.classList.remove('is-typing'));
@@ -141,6 +128,20 @@ if (typeof module === 'object') module.exports = {laneShellMode};
141
128
  function currentTab() {
142
129
  return data()?.tabs.find(t => t.position === data()?.active_pane?.tab_position);
143
130
  }
131
+ const GLYPHS = '◆◇▸✓', TONES = ['urgent', 'attention', 'working', 'done'];
132
+ function parseCounts(title) {
133
+ const c = {approval: 0, input: 0, working: 0, done: 0}, keys = Object.keys(c);
134
+ let found = 0;
135
+ for (const [, g, n] of String(title || '').matchAll(/([◆◇▸✓])(\d+)/g)) { c[keys[GLYPHS.indexOf(g)]] = +n; found = 1; }
136
+ return found ? c : null;
137
+ }
138
+ window.__lanesParseCounts = parseCounts;
139
+ const countParts = c => c ? Object.values(c).map((n, i) => [GLYPHS[i], n, TONES[i]]).filter(p => p[1]) : [];
140
+ const pickerTab = name => data()?.tabs.find(t => t.name === `${name} ☰`);
141
+ function pickerTitle(name) {
142
+ const picker = pickerTab(name);
143
+ return picker ? data()?.panes.find(p => p.tab_position === picker.position && !p.is_plugin)?.title || '' : '';
144
+ }
144
145
  function workspaces() {
145
146
  const tabs = data()?.tabs || [];
146
147
  const pickers = tabs.filter(t => t.name.endsWith(' ☰'));
@@ -176,12 +177,14 @@ if (typeof module === 'object') module.exports = {laneShellMode};
176
177
  const items = workspaces();
177
178
  const activeName = (currentTab()?.name || '').replace(/ ☰$/, '');
178
179
  const query = search.value.trim().toLocaleLowerCase();
179
- const signature = JSON.stringify([items, activeName, query]);
180
+ const titles = items.map(w => w.picker ? pickerTitle(w.name) : '');
181
+ const signature = JSON.stringify([items, titles, activeName, query]);
180
182
  if (signature === previousCards) return;
181
183
  previousCards = signature;
182
184
  list.replaceChildren();
183
185
  text(count, `${items.length} workspaces`);
184
- for (const item of items.filter(w => w.name.toLocaleLowerCase().includes(query))) {
186
+ for (const [index, item] of items.entries()) {
187
+ if (!item.name.toLocaleLowerCase().includes(query)) continue;
185
188
  const active = item.name === activeName;
186
189
  const card = make('button', `lanes-workspace${active ? ' is-current' : ''}`);
187
190
  card.type = 'button';
@@ -189,7 +192,10 @@ if (typeof module === 'object') module.exports = {laneShellMode};
189
192
  if (active) card.setAttribute('aria-current', 'true');
190
193
  const monogram = make('span', 'lanes-monogram', Array.from(item.name)[0]?.toUpperCase() || 'L');
191
194
  const copy = make('span', 'lanes-workspace-copy');
192
- copy.append(make('strong', '', item.name), make('small', '', item.picker ? 'Choose a lane' : 'Open workspace'));
195
+ const small = make('small'), parts = countParts(parseCounts(titles[index]));
196
+ parts.forEach(([g, n, tone], i) => small.append(i ? ' · ' : '', make('span', `lanes-tone-${tone}`, `${g} ${n}`)));
197
+ if (!parts.length) small.textContent = item.picker ? 'Choose a lane' : 'Open workspace';
198
+ copy.append(make('strong', '', item.name), small);
193
199
  const arrow = make('span', 'lanes-workspace-arrow');
194
200
  arrow.innerHTML = svg(active ? 'check' : 'arrow');
195
201
  card.append(monogram, copy, arrow);
@@ -214,8 +220,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
214
220
  dock = make('nav', 'lanes-dock'); dock.setAttribute('aria-label', 'Workspace controls');
215
221
  const spaces = button('Workspaces', 'workspaces', openSheet);
216
222
  lanesButton = button('Lanes', 'lanes', () => {
217
- const name = (currentTab()?.name || '').replace(/ ☰$/, '');
218
- const picker = data()?.tabs.find(t => t.name === `${name} ☰`);
223
+ const picker = pickerTab((currentTab()?.name || '').replace(/ ☰$/, ''));
219
224
  if (picker) focusTab(picker); else openSheet();
220
225
  });
221
226
  const panes = button('Panes', 'panes', () => nativeClick('.zj-pane'));
@@ -250,12 +255,10 @@ if (typeof module === 'object') module.exports = {laneShellMode};
250
255
  if (ev.shiftKey && document.activeElement === first) {ev.preventDefault(); last.focus();}
251
256
  else if (!ev.shiftKey && document.activeElement === last) {ev.preventDefault(); first.focus();}
252
257
  });
253
- shell.append(header, dock, sheet); document.body.append(shell);
258
+ quickRow = buildQuickRow();
259
+ shell.append(header, quickRow, dock, sheet); document.body.append(shell);
254
260
  if (mode === 'tablet') more.before(dock);
255
261
  root.classList.add('lanes-ready');
256
- // Upstream cancels pointerdown on extra keys, which also suppresses their
257
- // touch click in Safari. Cancel mousedown instead: keep typing focus while
258
- // allowing the native click to activate CTRL, arrows, and the other keys.
259
262
  for (const key of document.querySelectorAll('.zj-mobile-modbar button')) {
260
263
  key.addEventListener('pointerdown', ev => ev.stopImmediatePropagation(), true);
261
264
  key.addEventListener('mousedown', ev => ev.preventDefault());
@@ -264,31 +267,39 @@ if (typeof module === 'object') module.exports = {laneShellMode};
264
267
  resize();
265
268
  }
266
269
  function sendToTerminal(bytes) {
267
- // Inject bytes into the active Zellij terminal. Try the control API first,
268
- // then fall back to the keyboard capture element.
269
- if (window.__zjSendControl) {
270
- try { window.__zjSendControl({type: 'Write', bytes}); return; } catch (_) {}
271
- }
272
- const el = window.__zjSoftKbdCapture?.element;
273
- if (!el) return;
274
- const text = String.fromCharCode(...bytes);
275
- el.focus({preventScroll: true});
276
- try { document.execCommand('insertText', false, text); } catch (_) {}
270
+ try { window.__zjSendControl?.({type: 'Write', bytes}); } catch (_) {}
277
271
  }
278
272
  function applyLaneFilter(query) {
279
- const esc = [0x1b], slash = [0x2f];
280
- const bytes = [...esc, ...slash, ...Array.from(query).map(c => c.charCodeAt(0) & 0x7f).filter(b => b >= 0x20)];
281
- sendToTerminal(bytes);
273
+ const bytes = [...new TextEncoder().encode(query)].filter(b => b >= 0x20 && b !== 0x7f);
274
+ sendToTerminal([0x1b, 0x2f, ...bytes]);
275
+ }
276
+ function buildQuickRow() {
277
+ const row = make('div', 'lanes-quick');
278
+ 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);
282
+ 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');
288
+ down = -1;
289
+ });
290
+ row.append(key);
291
+ }
292
+ return row;
282
293
  }
283
294
  function clearLaneFilter() {
284
- sendToTerminal([0x1b]); // Esc clears the sidebar's filter mode
295
+ sendToTerminal([0x1b]);
285
296
  laneFilterPrev = '';
286
297
  if (laneFilterInput) laneFilterInput.value = '';
287
298
  }
288
299
  function buildLaneFilterBar() {
289
300
  const bar = make('div', 'lanes-filter-bar');
290
301
  bar.setAttribute('aria-label', 'Filter lanes');
291
- bar.setAttribute('hidden', '');
302
+ bar.hidden = true;
292
303
  bar.innerHTML = svg('search');
293
304
  laneFilterInput = make('input');
294
305
  laneFilterInput.type = 'search'; laneFilterInput.placeholder = 'Filter lanes';
@@ -317,6 +328,17 @@ if (typeof module === 'object') module.exports = {laneShellMode};
317
328
  text(document.getElementById('lanes-font-value'), `${fontSize} px`);
318
329
  resize();
319
330
  }
331
+ function lanesTheme() {
332
+ const css = getComputedStyle(root), v = p => css.getPropertyValue(p).trim();
333
+ const t = {background: v('--lanes-surface'), foreground: v('--lanes-ink'), cursor: v('--lanes-accent'),
334
+ selectionBackground: v('--lanes-accent-wash'), selectionForeground: v('--lanes-ink')};
335
+ LANES_ANSI[root.dataset.zjTheme === 'light' ? 'light' : 'dark'].forEach((c, i) => {
336
+ const k = 'Black Red Green Yellow Blue Magenta Cyan White'.split(' ')[i % 8];
337
+ t[i < 8 ? k.toLowerCase() : 'bright' + k] = c;
338
+ });
339
+ return t;
340
+ }
341
+ const applyTheme = () => { if (term) term.options.theme = lanesTheme(); };
320
342
  function installFontControls() {
321
343
  const row = make('div', 'lanes-font-controls');
322
344
  row.append(make('span', '', 'Text size'));
@@ -338,20 +360,32 @@ if (typeof module === 'object') module.exports = {laneShellMode};
338
360
  const name = tab?.name.replace(/ ☰$/, '') || 'Lanes';
339
361
  const picker = tab?.name.endsWith(' ☰');
340
362
  const pane = data()?.panes.find(p => p.pane_id === data()?.active_pane?.pane_id && !p.is_plugin);
363
+ const counts = parseCounts(pickerTitle(name));
364
+ const chips = countParts(counts).map(([glyph, n]) => glyph + n).join(' ');
341
365
  text(heading, name);
342
- text(subtitle, picker ? 'Choose a lane to continue' : pane?.title || 'Your workspace');
366
+ text(subtitle, picker ? chips || 'Choose a lane to continue' : pane?.title || 'Your workspace');
343
367
  lanesButton.setAttribute('aria-pressed', String(!!picker));
368
+ const needs = counts ? counts.approval + counts.input : 0;
369
+ if (needs) lanesButton.dataset.count = String(needs); else delete lanesButton.dataset.count;
344
370
  updateLaneFilterBar(picker);
345
- document.title = `${name} · Lanes`;
371
+ const quick = active && !picker;
372
+ if (quick !== quickOn) {
373
+ quickOn = quick;
374
+ quickRow.hidden = !quick;
375
+ root.style.setProperty('--lanes-quick', quick ? '48px' : '0px');
376
+ resize();
377
+ }
378
+ document.title = `${chips ? chips + ' · ' : ''}${name} · Lanes`;
346
379
  renderCards();
347
380
  }
348
381
  function connect() {
349
382
  if (api || !window.__zjMobileUi || !window.term || !window.__zjSoftKbdCapture?.element) return;
350
- // Wait for server state: resizing before the control socket opens makes 0.45.1
351
- // throw InvalidStateError and leaves the server with the old terminal size.
352
383
  if (!window.__zjMobileUi.getState()?.data?.tabs?.length) return;
353
384
  api = window.__zjMobileUi; term = window.term;
354
- build(); applyFont();
385
+ build(); applyFont(); applyTheme();
386
+ const sync = window.__zjSyncInactiveCursorStyle;
387
+ window.__zjSyncInactiveCursorStyle = () => { sync?.(); applyTheme(); };
388
+ new MutationObserver(applyTheme).observe(root, {attributes: true, attributeFilter: ['data-zj-theme']});
355
389
  const setData = api.setData;
356
390
  api.setData = function(value) {setData.call(api, value); refresh();};
357
391
  const nativeRoot = document.getElementById('zj-mobile-root');
@@ -372,14 +406,12 @@ if (typeof module === 'object') module.exports = {laneShellMode};
372
406
  window.visualViewport?.addEventListener('scroll', viewport);
373
407
  window.addEventListener('zellij:soft-keyboard-visibility', () => {
374
408
  viewport();
375
- // A rotation also shrinks the viewport. Correct upstream's height-only keyboard heuristic.
376
409
  requestAnimationFrame(() => window.__zjSendControl?.({
377
410
  type: 'SoftKeyboardVisibilityChanged', visible: root.classList.contains('lanes-keyboard'),
378
411
  }));
379
412
  });
380
413
  document.addEventListener('focusout', () => requestAnimationFrame(viewport));
381
414
  new MutationObserver(() => {styleLogin(); connect();}).observe(document.body, {childList: true});
382
- // The upstream window hooks are installed just after it appends its mobile root.
383
415
  const awaitingClient = setInterval(() => {connect(); if (api) clearInterval(awaitingClient);}, 200);
384
416
  styleLogin(); connect();
385
417
  })();
@@ -1,8 +1,10 @@
1
1
  // lanes host view configuration. Generated from the user's coder-launch keybinding scheme
2
2
  // (clear-defaults, Ctrl-Alt prefix: every ordinary key reaches the agent). Edit here; this file is versioned.
3
+ // Ctrl-g is never bound (plan Task 19): it reaches the lane (Claude Code opens its editor on it); the lock is
4
+ // Ctrl-Alt-g, in and out, and the LOCKED ribbon turns on-accent-urgent while it is on (spec §5.2).
3
5
  keybinds clear-defaults=true {
4
6
  normal {
5
- bind "Ctrl g" { SwitchToMode "locked"; }
7
+ bind "Ctrl Alt g" { SwitchToMode "locked"; }
6
8
  bind "Ctrl Alt p" { SwitchToMode "pane"; }
7
9
  bind "Ctrl Alt t" { SwitchToMode "tab"; }
8
10
  bind "Ctrl Alt n" { SwitchToMode "resize"; }
@@ -12,7 +14,7 @@ keybinds clear-defaults=true {
12
14
  bind "Ctrl Alt f" { SwitchToMode "scroll"; }
13
15
  }
14
16
  locked {
15
- bind "Ctrl g" { SwitchToMode "normal"; }
17
+ bind "Ctrl Alt g" { SwitchToMode "normal"; }
16
18
  }
17
19
  pane {
18
20
  bind "left" { MoveFocus "left"; }
@@ -164,19 +166,40 @@ keybinds clear-defaults=true {
164
166
  bind "Ctrl Alt End" { ScrollToBottom; SwitchToMode "normal"; }
165
167
  bind "Ctrl Alt c" { Copy; }
166
168
  bind "Ctrl Alt left" { GoToPreviousTab; }
167
- // lanes: workspaces are tabs (Ctrl-Alt-←/→); lanes live in the sidebar. These reach the sidebar
168
- // from anywhere in the tab: it opens the previous/next lane (or starts a filter) and hands focus back.
169
- //
170
- // WHY THREE MoveFocus "left": a workspace tab is [sidebar | view main | view b | view c] (wide views, at most
171
- // 3 viewports) and WriteChars types into whichever pane holds the focus BY THEN. One "left" reaches the
172
- // sidebar only from `main`; pressed in viewport b or c it stopped on a LANE and the "J"/"K" was typed into
173
- // the agent. Three lefts reach the sidebar from every viewport, and zellij does not wrap at the left edge, so
174
- // the surplus moves are no-ops. tests/view.py proves both (3 lefts land on the sidebar, a 4th stays there, the
175
- // real Ctrl-Alt-Down bytes from viewport 2 put no "J" on any lane). More viewports = more lefts here.
176
- // Known limit: a tab WITHOUT a sidebar (Host, Throttle, the mobile view tab) has nothing to the left, so there
177
- // the character still goes to the pane itself.
178
- bind "Ctrl Alt down" { MoveFocus "left"; MoveFocus "left"; MoveFocus "left"; WriteChars "J"; }
179
- bind "Ctrl Alt up" { MoveFocus "left"; MoveFocus "left"; MoveFocus "left"; WriteChars "K"; }
169
+ // lanes: workspaces are tabs (Ctrl-Alt-←/→); lanes live in the sidebar. Next / previous lane (plan Task 19)
170
+ // types nothing: `lanes next|prev --focused` points the FOCUSED viewport (the first one when the sidebar has
171
+ // the focus) at the neighbouring lane of its board, exactly as Enter in the sidebar does, from a floating pane
172
+ // that closes when it exits. The focus stays where it was. On a tab without a board (Host, Throttle) it does
173
+ // nothing — the old WriteChars bind typed its "J"/"K" into that tab's pane. "lanes" is found on the view
174
+ // server's PATH (bin/lanes clean_env), as for Ctrl-Alt-m below.
175
+ bind "Ctrl Alt down" {
176
+ Run "lanes" "next" "--focused" {
177
+ floating true
178
+ close_on_exit true
179
+ name "Lanes"
180
+ x "0%"
181
+ y "0%"
182
+ width "1%"
183
+ height "1%"
184
+ }
185
+ }
186
+ bind "Ctrl Alt up" {
187
+ Run "lanes" "prev" "--focused" {
188
+ floating true
189
+ close_on_exit true
190
+ name "Lanes"
191
+ x "0%"
192
+ y "0%"
193
+ width "1%"
194
+ height "1%"
195
+ }
196
+ }
197
+ // These two still hand a key to the SIDEBAR (a filter and the upload box live there, so the focus must end
198
+ // on it). WHY THREE MoveFocus "left": a workspace tab is [sidebar | view main | view b | view c] (wide views,
199
+ // at most 3 viewports) and WriteChars types into whichever pane holds the focus BY THEN; three lefts reach
200
+ // the sidebar from every viewport and zellij does not wrap at the left edge (tests/view.py). Known limit: a
201
+ // tab WITHOUT a sidebar (Host, Throttle, the mobile view tab) has nothing to the left, so there the
202
+ // character still goes to the pane itself.
180
203
  bind "Ctrl Alt /" { MoveFocus "left"; MoveFocus "left"; MoveFocus "left"; WriteChars "/"; }
181
204
  // Arm the sidebar's upload box, then drop files anywhere on the window (a drop = a paste into the focused pane).
182
205
  bind "Ctrl Alt u" { MoveFocus "left"; MoveFocus "left"; MoveFocus "left"; WriteChars "U"; }
@@ -188,6 +211,24 @@ keybinds clear-defaults=true {
188
211
  }
189
212
  }
190
213
  }
214
+ // Markdown preview (spec §5.4): the newest markdown file the focused lane named, in a floating pane that closes
215
+ // when the pager quits. Both Run forms parse on Zellij 0.45.1 (measured 2026-10-04, DEF-1453); the multi-line
216
+ // children are the pinned style, and test_ctrl_alt_m_is_the_multi_line_floating_run_block pins them. "lanes"
217
+ // is found on the view server's PATH
218
+ // (bin/lanes clean_env: this checkout's bin/), because this file is read in place, never rendered per host.
219
+ shared_except "locked" {
220
+ bind "Ctrl Alt m" {
221
+ Run "lanes" "preview" "--focused" {
222
+ floating true
223
+ close_on_exit true
224
+ name "Preview"
225
+ x "10%"
226
+ y "5%"
227
+ width "80%"
228
+ height "90%"
229
+ }
230
+ }
231
+ }
191
232
  shared_except "normal" "locked" {
192
233
  bind "Alt left" { MoveFocusOrTab "left"; }
193
234
  bind "Alt down" { MoveFocus "down"; }
@@ -199,7 +240,7 @@ keybinds clear-defaults=true {
199
240
  bind "Alt [" { PreviousSwapLayout; }
200
241
  bind "Alt ]" { NextSwapLayout; }
201
242
  bind "Alt f" { ToggleFloatingPanes; }
202
- bind "Ctrl g" { SwitchToMode "locked"; }
243
+ bind "Ctrl Alt g" { SwitchToMode "locked"; }
203
244
  bind "Alt h" { MoveFocusOrTab "left"; }
204
245
  bind "Alt i" { MoveTab "left"; }
205
246
  bind "Alt j" { MoveFocus "down"; }
@@ -395,6 +436,16 @@ web_client {
395
436
  }
396
437
  }
397
438
  // END lanes tokens
439
+ // Chrome: straight frames to match the sidebar's ─ rules; the session name is noise in a 14-tab bar; simplified_ui
440
+ // removes the compact-bar's Private-Use-Area chevrons — the one glyph the cockpit needed a Nerd Font for. Tabs
441
+ // render as [ Sakio ] ribbons: accent-on-dark for the active one (ribbon_selected), dim-on-raised for the rest.
442
+ ui {
443
+ pane_frames {
444
+ rounded_corners false
445
+ hide_session_name true
446
+ }
447
+ }
448
+ simplified_ui true
398
449
  default_mode "normal"
399
450
  mouse_mode true
400
451
  copy_clipboard "system"
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@vibelord/lanes",
3
- "version": "1.0.1",
4
- "commit": "7eefe03dd9362ca62d05abd2d23cbc958d6af346",
5
- "digest": "sha256:09b4d87fb5687fe5e72140baa68f9518ec96cbf9a3b08c55d7f9e99afb55b295",
3
+ "version": "1.0.2",
4
+ "commit": "a8c1fcdf78e24125b9d4c0b32618a16448445acc",
5
+ "digest": "sha256:162bffbf04a2c3a478fe1fce82b9953e609b5450edd106bba28e265df9ba464a",
6
6
  "executables": [
7
7
  "bin/lanes",
8
8
  "client/install.sh",
@@ -12,6 +12,7 @@
12
12
  "client/omarchy/discover.sh",
13
13
  "container/lane",
14
14
  "container/lane-run",
15
+ "container/lane-signal",
15
16
  "holders/exec.sh",
16
17
  "holders/ssh.sh",
17
18
  "holders/tmux.sh",
@@ -1,130 +0,0 @@
1
- """Track pre-persistence Docker shells without pretending their lost Docker connection is reattachable."""
2
- import json
3
- import os
4
- import select
5
- import signal
6
- import sys
7
- import time
8
- from pathlib import Path
9
-
10
- import lanes_lib as L
11
-
12
-
13
- def process(pid):
14
- root = Path(L.proc_root()) / str(pid)
15
- stat = (root / 'stat').read_text().rsplit(') ', 1)[1].split()
16
- return {'pid': int(pid), 'ppid': int(stat[1]), 'sid': int(stat[3]), 'tty': stat[4], 'start': stat[19],
17
- 'uid': root.stat().st_uid, 'cgroup': (root / 'cgroup').read_text(),
18
- 'cwd': os.readlink(root / 'cwd'), 'command': (root / 'comm').read_text().strip()}
19
-
20
-
21
- def observe():
22
- lanes, groups = {}, {}
23
- for path in (L.STATE / 'legacy-sessions').glob('*.json'):
24
- entry = L.read_json(path) or {}
25
- identity = entry.get('legacy') or {}
26
- try:
27
- actual = process(identity['pid'])
28
- if actual['start'] != identity['start']:
29
- continue
30
- state = {'state': 'quiet', 'pid': actual['pid'], 'cwd': actual['cwd'],
31
- 'command': actual['command'], 'legacy': True}
32
- except (FileNotFoundError, ProcessLookupError, KeyError):
33
- continue
34
- except OSError:
35
- state = {'state': 'unknown', 'observed': 'error', 'legacy': True}
36
- lanes[entry['id']] = state
37
- groups.setdefault(entry['workspace'], []).append(entry)
38
- L.atomic_write(L.STATE / 'resolve.dynamic' / entry['id'],
39
- '\n'.join(['exec', entry['container'], 'legacy', entry['id'], entry['name']]) + '\n')
40
- return lanes, groups
41
-
42
-
43
- def close(entry):
44
- identity = entry.get('legacy') or {}
45
- recorded = L.read_json(L.STATE / 'legacy-sessions' / (entry['id'] + '.json')) or {}
46
- if recorded.get('legacy') != identity:
47
- raise L.LanesError('Legacy shell identity changed; nothing was closed')
48
- try:
49
- root = process(identity['pid'])
50
- except (OSError, KeyError):
51
- raise L.LanesError('Legacy shell could not be checked; nothing was closed')
52
- if root['start'] != identity['start'] or root['uid'] != os.getuid():
53
- raise L.LanesError('Legacy shell changed; nothing was closed')
54
- members = {}
55
- for path in Path(L.proc_root()).iterdir():
56
- if not path.name.isdigit():
57
- continue
58
- try:
59
- candidate = process(path.name)
60
- if candidate['uid'] == root['uid'] and candidate['cgroup'] == root['cgroup']:
61
- members[candidate['pid']] = candidate
62
- except OSError:
63
- continue
64
- # The root may have exited and its PID been reused while we scanned /proc.
65
- # Keep the recorded identity authoritative rather than adopting the scan's replacement.
66
- if members.get(root['pid'], {}).get('start') != root['start']:
67
- raise L.LanesError('Legacy shell changed or could not be checked; nothing was closed')
68
- chosen = {root['pid']} | {pid for pid, p in members.items()
69
- if p['sid'] == root['sid'] and p['tty'] == root['tty'] and root['tty'] != '0'}
70
- while True:
71
- children = {pid for pid, p in members.items() if p['ppid'] in chosen}
72
- if children <= chosen:
73
- break
74
- chosen.update(children)
75
- handles, root_verified = [], False
76
- try:
77
- for pid in chosen:
78
- fd = None
79
- try:
80
- fd = os.pidfd_open(pid)
81
- if process(pid)['start'] != members[pid]['start']:
82
- os.close(fd)
83
- continue
84
- handles.append(fd)
85
- if pid == root['pid']:
86
- root_verified = True
87
- except (OSError, KeyError):
88
- if fd is not None:
89
- os.close(fd)
90
- continue
91
- if not root_verified:
92
- raise L.LanesError('Legacy shell could not be checked; nothing was closed')
93
- # pidfds remain attached to these processes even if the kernel recycles their numeric pids.
94
- for fd in handles:
95
- try:
96
- signal.pidfd_send_signal(fd, signal.SIGHUP)
97
- except ProcessLookupError:
98
- pass
99
- remaining, deadline = set(handles), time.monotonic() + 2
100
- while remaining and time.monotonic() < deadline:
101
- ready, _, _ = select.select(list(remaining), [], [], 0.1)
102
- remaining.difference_update(ready)
103
- for fd in remaining:
104
- try:
105
- signal.pidfd_send_signal(fd, signal.SIGKILL)
106
- except ProcessLookupError:
107
- pass
108
- finally:
109
- for fd in handles:
110
- os.close(fd)
111
- (L.STATE / 'legacy-sessions' / (entry['id'] + '.json')).unlink(missing_ok=True)
112
-
113
-
114
- def view(container, lane_id):
115
- L._require(L.IDENT, lane_id, 'legacy shell')
116
- entry = L.read_json(L.STATE / 'legacy-sessions' / (lane_id + '.json')) or {}
117
- if entry.get('container') != container:
118
- raise L.LanesError('Legacy shell no longer exists')
119
- print('\033[2J\033[H\n ' + entry['name'] + '\n', flush=True)
120
- print(' This shell predates persistent terminals. Its original Docker connection cannot be reattached.\n'
121
- ' Its shell and running jobs are preserved. Use × in Unregistered to terminate them.\n'
122
- ' New + creates a shell you can leave and reopen.\n', flush=True)
123
- print(' Container: ' + container + '\n Process: ' + str(entry['legacy']['pid']), flush=True)
124
- while True:
125
- time.sleep(30)
126
-
127
-
128
- if __name__ == '__main__':
129
- if len(sys.argv) == 4 and sys.argv[1] == 'view':
130
- view(sys.argv[2], sys.argv[3])