@vibelord/lanes 1.0.0 → 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 (56) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +92 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +928 -150
  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 +62 -5
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +8 -0
  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 +326 -25
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +158 -6
  17. package/payload/lanes/docs/DESIGN.md +250 -0
  18. package/payload/lanes/docs/INSTALL.md +39 -0
  19. package/payload/lanes/docs/RUNBOOK.md +507 -24
  20. package/payload/lanes/docs/SHARING.md +1 -1
  21. package/payload/lanes/holders/exec.sh +2 -5
  22. package/payload/lanes/holders/ssh.sh +18 -0
  23. package/payload/lanes/holders.lock +3 -0
  24. package/payload/lanes/install/enable-phone.sh +15 -6
  25. package/payload/lanes/install/enable-web.sh +19 -6
  26. package/payload/lanes/install/host-install.sh +10 -1
  27. package/payload/lanes/install/services.py +30 -3
  28. package/payload/lanes/install.sh +108 -3
  29. package/payload/lanes/libexec/agent_state.py +246 -0
  30. package/payload/lanes/libexec/connection.py +178 -0
  31. package/payload/lanes/libexec/cursor_sync.py +147 -6
  32. package/payload/lanes/libexec/device_sync.py +134 -0
  33. package/payload/lanes/libexec/economy_poll.py +685 -0
  34. package/payload/lanes/libexec/guard.py +27 -1
  35. package/payload/lanes/libexec/lanes_lib.py +436 -36
  36. package/payload/lanes/libexec/notify.py +317 -0
  37. package/payload/lanes/libexec/registrations.py +148 -0
  38. package/payload/lanes/libexec/session_actions.py +53 -22
  39. package/payload/lanes/libexec/sidebar.py +1190 -186
  40. package/payload/lanes/libexec/statusd.py +352 -90
  41. package/payload/lanes/libexec/tokens.py +310 -0
  42. package/payload/lanes/libexec/tokens.sh +11 -0
  43. package/payload/lanes/libexec/view.sh +22 -11
  44. package/payload/lanes/systemd/lanes-economy.service +20 -0
  45. package/payload/lanes/systemd/lanes-notify.service +19 -0
  46. package/payload/lanes/systemd/lanes-sync.service +3 -0
  47. package/payload/lanes/web/README.md +96 -2
  48. package/payload/lanes/web/browser.css +22 -0
  49. package/payload/lanes/web/browser.js +73 -0
  50. package/payload/lanes/web/index.html +2 -1
  51. package/payload/lanes/web/mobile.css +108 -221
  52. package/payload/lanes/web/mobile.js +105 -55
  53. package/payload/lanes/workspaces.example/README.md +9 -3
  54. package/payload/lanes/zellij/config.kdl +128 -19
  55. package/payload/manifest.json +7 -3
  56. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -1,16 +1,31 @@
1
- /* Lanes mobile shell. Uses the public window hooks in the pinned Zellij 0.45.1 client.
2
- * Authentication, terminal input, rendering, and transport remain in the upstream client. */
1
+ /* Lanes browser shell; see web/README.md, Code notes */
2
+ function laneShellMode({userAgent, coarse, width, height} = {}) {
3
+ if (/iPhone|iPod|Android.*Mobile/i.test(userAgent) || (coarse && (width <= 600 || height <= 500))) return 'phone';
4
+ return coarse ? 'tablet' : 'desktop';
5
+ }
6
+ if (typeof module === 'object') module.exports = {laneShellMode};
3
7
  (() => {
4
8
  'use strict';
5
- const isPhone = /iPhone|iPod|Android.*Mobile/i.test(navigator.userAgent) ||
6
- (matchMedia('(pointer: coarse)').matches && innerWidth <= 600);
7
- if (!isPhone) return;
9
+ if (typeof document === 'undefined') return;
10
+ const mode = laneShellMode({userAgent: navigator.userAgent, coarse: matchMedia('(pointer: coarse)').matches,
11
+ width: innerWidth, height: innerHeight});
8
12
  const root = document.documentElement;
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') {
18
+ const add = (tag, props) => document.head.append(Object.assign(document.createElement(tag), props));
19
+ add('link', {rel: 'stylesheet', href: '/lanes-assets/browser.css'});
20
+ add('script', {src: '/lanes-assets/browser.js'});
21
+ }
22
+ if (mode === 'desktop') return;
9
23
  root.classList.add('lanes-mobile');
24
+ if (mode === 'tablet') {
25
+ root.classList.add('lanes-tablet');
26
+ try {localStorage.getItem('zellij:mobile-ui') || localStorage.setItem('zellij:mobile-ui', 'on');} catch (_) {}
27
+ }
10
28
 
11
- // Only a browser's native tap/focus on an input may summon the keyboard. Zellij
12
- // otherwise refocuses a hidden input on touchstart, touchend, click and server
13
- // SetSoftKeyboard messages. Install before its first terminal or login modal.
14
29
  const elementFocus = HTMLElement.prototype.focus;
15
30
  HTMLElement.prototype.focus = function(...args) {
16
31
  const textInput = this instanceof HTMLInputElement &&
@@ -18,11 +33,7 @@
18
33
  if (textInput || this instanceof HTMLTextAreaElement || this.isContentEditable) return;
19
34
  return elementFocus.apply(this, args);
20
35
  };
21
- Object.defineProperty(window, '__zjSoftKbdEnabled', {
22
- configurable: false,
23
- get: () => false,
24
- set: () => {},
25
- });
36
+ Object.defineProperty(window, '__zjSoftKbdEnabled', {configurable: false, get: () => false, set: () => {}});
26
37
  try {
27
38
  const theme = localStorage.getItem('zellij:mobile-theme') || 'dark';
28
39
  localStorage.setItem('zellij:mobile-theme', theme);
@@ -56,7 +67,7 @@
56
67
  el.addEventListener('click', action);
57
68
  return el;
58
69
  };
59
- 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;
60
71
  let laneFilterBar, laneFilterInput, laneFilterPrev = '';
61
72
  let api, term, previousCards = '', sheetOpen = false, returnFocus;
62
73
  let fontSize = 16;
@@ -75,9 +86,7 @@
75
86
  root.style.setProperty('--lanes-vw', `${vv?.width ?? innerWidth}px`);
76
87
  root.style.setProperty('--lanes-offset-top', `${vv?.offsetTop ?? 0}px`);
77
88
  root.style.setProperty('--lanes-offset-left', `${vv?.offsetLeft ?? 0}px`);
78
- // Browser toolbars alone must not put the UI into keyboard mode.
79
89
  const occluded = !!vv && innerHeight - vv.height - vv.offsetTop > 120;
80
- // Zellij's keyboard input lives in a closed shadow root, so activeElement is its host.
81
90
  const focused = window.__zjSoftKbdCapture?.isFocused || document.activeElement?.matches('input, textarea');
82
91
  setKeyboard(occluded && focused);
83
92
  }
@@ -97,19 +106,14 @@
97
106
  label.innerHTML = svg('keyboard');
98
107
  label.append(make('span', '', 'Type here'));
99
108
  label.setAttribute('aria-hidden', 'true');
100
- // Keep the original input and its event handlers inside its closed shadow
101
- // root (including its protection from password-manager extensions). Make it
102
- // a real, tappable field instead of a 1px invisible focus target. Keystrokes
103
- // appear in the terminal; its internal padding/history stays invisible.
104
109
  host.removeAttribute('aria-hidden');
105
110
  host.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:1;pointer-events:auto;';
106
111
  capture.removeAttribute('aria-hidden');
107
112
  capture.setAttribute('aria-label', 'Type in terminal');
108
113
  capture.tabIndex = 0;
109
- capture.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;box-sizing:border-box;' +
110
- 'font-size:16px;border:0;border-radius:12px;padding:0;margin:0;opacity:1;' +
111
- 'background:transparent;color:transparent;caret-color:transparent;outline:none;' +
112
- '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;';
113
117
  field.append(label, host);
114
118
  capture.addEventListener('focus', () => field.classList.add('is-typing'));
115
119
  capture.addEventListener('blur', () => field.classList.remove('is-typing'));
@@ -124,6 +128,20 @@
124
128
  function currentTab() {
125
129
  return data()?.tabs.find(t => t.position === data()?.active_pane?.tab_position);
126
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
+ }
127
145
  function workspaces() {
128
146
  const tabs = data()?.tabs || [];
129
147
  const pickers = tabs.filter(t => t.name.endsWith(' ☰'));
@@ -159,12 +177,14 @@
159
177
  const items = workspaces();
160
178
  const activeName = (currentTab()?.name || '').replace(/ ☰$/, '');
161
179
  const query = search.value.trim().toLocaleLowerCase();
162
- 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]);
163
182
  if (signature === previousCards) return;
164
183
  previousCards = signature;
165
184
  list.replaceChildren();
166
185
  text(count, `${items.length} workspaces`);
167
- 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;
168
188
  const active = item.name === activeName;
169
189
  const card = make('button', `lanes-workspace${active ? ' is-current' : ''}`);
170
190
  card.type = 'button';
@@ -172,7 +192,10 @@
172
192
  if (active) card.setAttribute('aria-current', 'true');
173
193
  const monogram = make('span', 'lanes-monogram', Array.from(item.name)[0]?.toUpperCase() || 'L');
174
194
  const copy = make('span', 'lanes-workspace-copy');
175
- 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);
176
199
  const arrow = make('span', 'lanes-workspace-arrow');
177
200
  arrow.innerHTML = svg(active ? 'check' : 'arrow');
178
201
  card.append(monogram, copy, arrow);
@@ -197,8 +220,7 @@
197
220
  dock = make('nav', 'lanes-dock'); dock.setAttribute('aria-label', 'Workspace controls');
198
221
  const spaces = button('Workspaces', 'workspaces', openSheet);
199
222
  lanesButton = button('Lanes', 'lanes', () => {
200
- const name = (currentTab()?.name || '').replace(/ ☰$/, '');
201
- const picker = data()?.tabs.find(t => t.name === `${name} ☰`);
223
+ const picker = pickerTab((currentTab()?.name || '').replace(/ ☰$/, ''));
202
224
  if (picker) focusTab(picker); else openSheet();
203
225
  });
204
226
  const panes = button('Panes', 'panes', () => nativeClick('.zj-pane'));
@@ -233,11 +255,10 @@
233
255
  if (ev.shiftKey && document.activeElement === first) {ev.preventDefault(); last.focus();}
234
256
  else if (!ev.shiftKey && document.activeElement === last) {ev.preventDefault(); first.focus();}
235
257
  });
236
- shell.append(header, dock, sheet); document.body.append(shell);
258
+ quickRow = buildQuickRow();
259
+ shell.append(header, quickRow, dock, sheet); document.body.append(shell);
260
+ if (mode === 'tablet') more.before(dock);
237
261
  root.classList.add('lanes-ready');
238
- // Upstream cancels pointerdown on extra keys, which also suppresses their
239
- // touch click in Safari. Cancel mousedown instead: keep typing focus while
240
- // allowing the native click to activate CTRL, arrows, and the other keys.
241
262
  for (const key of document.querySelectorAll('.zj-mobile-modbar button')) {
242
263
  key.addEventListener('pointerdown', ev => ev.stopImmediatePropagation(), true);
243
264
  key.addEventListener('mousedown', ev => ev.preventDefault());
@@ -246,31 +267,39 @@
246
267
  resize();
247
268
  }
248
269
  function sendToTerminal(bytes) {
249
- // Inject bytes into the active Zellij terminal. Try the control API first,
250
- // then fall back to the keyboard capture element.
251
- if (window.__zjSendControl) {
252
- try { window.__zjSendControl({type: 'Write', bytes}); return; } catch (_) {}
253
- }
254
- const el = window.__zjSoftKbdCapture?.element;
255
- if (!el) return;
256
- const text = String.fromCharCode(...bytes);
257
- el.focus({preventScroll: true});
258
- try { document.execCommand('insertText', false, text); } catch (_) {}
270
+ try { window.__zjSendControl?.({type: 'Write', bytes}); } catch (_) {}
259
271
  }
260
272
  function applyLaneFilter(query) {
261
- const esc = [0x1b], slash = [0x2f];
262
- const bytes = [...esc, ...slash, ...Array.from(query).map(c => c.charCodeAt(0) & 0x7f).filter(b => b >= 0x20)];
263
- 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;
264
293
  }
265
294
  function clearLaneFilter() {
266
- sendToTerminal([0x1b]); // Esc clears the sidebar's filter mode
295
+ sendToTerminal([0x1b]);
267
296
  laneFilterPrev = '';
268
297
  if (laneFilterInput) laneFilterInput.value = '';
269
298
  }
270
299
  function buildLaneFilterBar() {
271
300
  const bar = make('div', 'lanes-filter-bar');
272
301
  bar.setAttribute('aria-label', 'Filter lanes');
273
- bar.setAttribute('hidden', '');
302
+ bar.hidden = true;
274
303
  bar.innerHTML = svg('search');
275
304
  laneFilterInput = make('input');
276
305
  laneFilterInput.type = 'search'; laneFilterInput.placeholder = 'Filter lanes';
@@ -299,6 +328,17 @@
299
328
  text(document.getElementById('lanes-font-value'), `${fontSize} px`);
300
329
  resize();
301
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(); };
302
342
  function installFontControls() {
303
343
  const row = make('div', 'lanes-font-controls');
304
344
  row.append(make('span', '', 'Text size'));
@@ -320,20 +360,32 @@
320
360
  const name = tab?.name.replace(/ ☰$/, '') || 'Lanes';
321
361
  const picker = tab?.name.endsWith(' ☰');
322
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(' ');
323
365
  text(heading, name);
324
- 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');
325
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;
326
370
  updateLaneFilterBar(picker);
327
- 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`;
328
379
  renderCards();
329
380
  }
330
381
  function connect() {
331
382
  if (api || !window.__zjMobileUi || !window.term || !window.__zjSoftKbdCapture?.element) return;
332
- // Wait for server state: resizing before the control socket opens makes 0.45.1
333
- // throw InvalidStateError and leaves the server with the old terminal size.
334
383
  if (!window.__zjMobileUi.getState()?.data?.tabs?.length) return;
335
384
  api = window.__zjMobileUi; term = window.term;
336
- 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']});
337
389
  const setData = api.setData;
338
390
  api.setData = function(value) {setData.call(api, value); refresh();};
339
391
  const nativeRoot = document.getElementById('zj-mobile-root');
@@ -354,14 +406,12 @@
354
406
  window.visualViewport?.addEventListener('scroll', viewport);
355
407
  window.addEventListener('zellij:soft-keyboard-visibility', () => {
356
408
  viewport();
357
- // A rotation also shrinks the viewport. Correct upstream's height-only keyboard heuristic.
358
409
  requestAnimationFrame(() => window.__zjSendControl?.({
359
410
  type: 'SoftKeyboardVisibilityChanged', visible: root.classList.contains('lanes-keyboard'),
360
411
  }));
361
412
  });
362
413
  document.addEventListener('focusout', () => requestAnimationFrame(viewport));
363
414
  new MutationObserver(() => {styleLogin(); connect();}).observe(document.body, {childList: true});
364
- // The upstream window hooks are installed just after it appends its mobile root.
365
415
  const awaitingClient = setInterval(() => {connect(); if (api) clearInterval(awaitingClient);}, 200);
366
416
  styleLogin(); connect();
367
417
  })();
@@ -13,10 +13,16 @@ Copy this folder to `~/.config/lanes/workspaces/` and edit it.
13
13
  | `catalog.json` | The boards, in tab order: `id` (also the uploads/watch-folder name), `title` (the tab), `manifest`. |
14
14
  | `workspaces.<board>.json` | Where the board's lanes come from: one or more `repositories`, each naming the workspace **container** (`host`) and the board's terminal registry (`registry`: the repository's `.vscode/terminals.json`). Optional per repository: `prefix` (prepended to lane titles), `displayNames`, `bindingOverrides` (tmux socket/session for a terminal whose registry entry has none), `cursor` (a second file to project the board into, for boards whose registry lives outside a repository). `monitor` adds the full-pane Throttle monitor tab. |
15
15
  | `commit-targets.json` | Only for `lanes cursor-commit`: repository, branch and author for committing the normalized registries. |
16
+ | `registered-sessions.json` | Optional, created by **Add to workspace** or `lanes register`: `version: 1`, plus `sessions` records with `id`, `workspace`, `name`, `container`, `tmux: {socket, session}`, and `folder`. Preserve this file in site backups. |
16
17
 
17
- A lane is whatever the registry says it is: `lanes sync` (every two minutes, read-only) turns each terminal into a
18
- lane with a stable id `<board>--<slug of the terminal name>`. Adding a lane = adding a terminal to the repository's
19
- `.vscode/terminals.json`; nothing in this folder changes.
18
+ A lane is whatever the registry says it is: `lanes sync` (every two minutes, read-only) turns each repository
19
+ terminal into a lane with a stable id `<board>--<slug of the terminal name>` (with the repository `prefix`). A
20
+ `displayNames` entry, which the sidebar `R` key and `lanes rename <lane-id> <name>` write into the repository's block
21
+ of `workspaces.<board>.json` (first write keeps a `.bak-<UTC>` copy beside it), changes only the shown name, never the
22
+ id. Add a terminal to `.vscode/terminals.json` for an editor-defined lane.
23
+ Alternatively, use **+** beside a discovered session to register its existing binding and ID in a workspace
24
+ using the same container. Sync includes these local registrations; they never run a create command.
25
+ Repository declarations take precedence when they declare the same binding. Legacy Docker shells cannot be adopted.
20
26
 
21
27
  After editing: `lanes sync && lanes doctor`.
22
28
 
@@ -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"; }
@@ -331,12 +372,80 @@ env {
331
372
  FORCE_COLOR "3"
332
373
  FORCE_HYPERLINK "1"
333
374
  }
375
+ osc8_hyperlinks true
376
+ // BEGIN lanes tokens (generated by libexec/tokens.py --kdl; do not edit by hand)
377
+ themes {
378
+ lanes-dark {
379
+ text_unselected { base "#edf1f5"; background "#101318"; emphasis_0 "#e5b84a"; emphasis_1 "#6fd39a"; emphasis_2 "#b4e3bf"; emphasis_3 "#7fb3f2"; }
380
+ text_selected { base "#edf1f5"; background "#1a2721"; emphasis_0 "#e5b84a"; emphasis_1 "#6fd39a"; emphasis_2 "#b4e3bf"; emphasis_3 "#7fb3f2"; }
381
+ ribbon_selected { base "#101318"; background "#b4e3bf"; emphasis_0 "#7a1a12"; emphasis_1 "#101318"; emphasis_2 "#101318"; emphasis_3 "#101318"; }
382
+ ribbon_unselected { base "#9ca7b5"; background "#191e25"; emphasis_0 "#f28b7d"; emphasis_1 "#edf1f5"; emphasis_2 "#e5b84a"; emphasis_3 "#7fb3f2"; }
383
+ table_title { base "#b4e3bf"; background "#101318"; emphasis_0 "#e5b84a"; emphasis_1 "#6fd39a"; emphasis_2 "#b4e3bf"; emphasis_3 "#7fb3f2"; }
384
+ table_cell_selected { base "#edf1f5"; background "#1a2721"; emphasis_0 "#e5b84a"; emphasis_1 "#6fd39a"; emphasis_2 "#b4e3bf"; emphasis_3 "#7fb3f2"; }
385
+ table_cell_unselected { base "#edf1f5"; background "#101318"; emphasis_0 "#e5b84a"; emphasis_1 "#6fd39a"; emphasis_2 "#b4e3bf"; emphasis_3 "#7fb3f2"; }
386
+ list_selected { base "#edf1f5"; background "#1a2721"; emphasis_0 "#e5b84a"; emphasis_1 "#6fd39a"; emphasis_2 "#b4e3bf"; emphasis_3 "#7fb3f2"; }
387
+ list_unselected { base "#edf1f5"; background "#101318"; emphasis_0 "#e5b84a"; emphasis_1 "#6fd39a"; emphasis_2 "#b4e3bf"; emphasis_3 "#7fb3f2"; }
388
+ frame_selected { base "#b4e3bf"; background "#101318"; emphasis_0 "#e5b84a"; emphasis_1 "#6fd39a"; emphasis_2 "#7fb3f2"; emphasis_3 "#f28b7d"; }
389
+ frame_highlight { base "#e5b84a"; background "#101318"; emphasis_0 "#f28b7d"; emphasis_1 "#6fd39a"; emphasis_2 "#b4e3bf"; emphasis_3 "#7fb3f2"; }
390
+ exit_code_success { base "#6fd39a"; background "#101318"; emphasis_0 "#6fd39a"; emphasis_1 "#6fd39a"; emphasis_2 "#6fd39a"; emphasis_3 "#6fd39a"; }
391
+ exit_code_error { base "#f28b7d"; background "#101318"; emphasis_0 "#f28b7d"; emphasis_1 "#f28b7d"; emphasis_2 "#f28b7d"; emphasis_3 "#f28b7d"; }
392
+ multiplayer_user_colors { player_1 "#7fb3f2"; player_2 "#e5b84a"; player_3 "#6fd39a"; player_4 "#f28b7d"; player_5 "#b4e3bf"; player_6 "#c9a6ff"; player_7 "#6fd6d0"; player_8 "#f7cf7f"; player_9 "#8fe0a3"; player_10 "#ff9d96"; }
393
+ }
394
+ lanes-light {
395
+ text_unselected { base "#17241b"; background "#f3f5f2"; emphasis_0 "#8a5a00"; emphasis_1 "#1b7142"; emphasis_2 "#2f6b45"; emphasis_3 "#2456a8"; }
396
+ text_selected { base "#17241b"; background "#e5f0e6"; emphasis_0 "#8a5a00"; emphasis_1 "#1b7142"; emphasis_2 "#2f6b45"; emphasis_3 "#2456a8"; }
397
+ ribbon_selected { base "#ffffff"; background "#2f6b45"; emphasis_0 "#ffd3cc"; emphasis_1 "#ffffff"; emphasis_2 "#ffffff"; emphasis_3 "#ffffff"; }
398
+ ribbon_unselected { base "#56616d"; background "#ffffff"; emphasis_0 "#b7322a"; emphasis_1 "#17241b"; emphasis_2 "#8a5a00"; emphasis_3 "#2456a8"; }
399
+ table_title { base "#2f6b45"; background "#f3f5f2"; emphasis_0 "#8a5a00"; emphasis_1 "#1b7142"; emphasis_2 "#2f6b45"; emphasis_3 "#2456a8"; }
400
+ table_cell_selected { base "#17241b"; background "#e5f0e6"; emphasis_0 "#8a5a00"; emphasis_1 "#1b7142"; emphasis_2 "#2f6b45"; emphasis_3 "#2456a8"; }
401
+ table_cell_unselected { base "#17241b"; background "#f3f5f2"; emphasis_0 "#8a5a00"; emphasis_1 "#1b7142"; emphasis_2 "#2f6b45"; emphasis_3 "#2456a8"; }
402
+ list_selected { base "#17241b"; background "#e5f0e6"; emphasis_0 "#8a5a00"; emphasis_1 "#1b7142"; emphasis_2 "#2f6b45"; emphasis_3 "#2456a8"; }
403
+ list_unselected { base "#17241b"; background "#f3f5f2"; emphasis_0 "#8a5a00"; emphasis_1 "#1b7142"; emphasis_2 "#2f6b45"; emphasis_3 "#2456a8"; }
404
+ frame_selected { base "#2f6b45"; background "#f3f5f2"; emphasis_0 "#8a5a00"; emphasis_1 "#1b7142"; emphasis_2 "#2456a8"; emphasis_3 "#b7322a"; }
405
+ frame_highlight { base "#8a5a00"; background "#f3f5f2"; emphasis_0 "#b7322a"; emphasis_1 "#1b7142"; emphasis_2 "#2f6b45"; emphasis_3 "#2456a8"; }
406
+ exit_code_success { base "#1b7142"; background "#f3f5f2"; emphasis_0 "#1b7142"; emphasis_1 "#1b7142"; emphasis_2 "#1b7142"; emphasis_3 "#1b7142"; }
407
+ exit_code_error { base "#b7322a"; background "#f3f5f2"; emphasis_0 "#b7322a"; emphasis_1 "#b7322a"; emphasis_2 "#b7322a"; emphasis_3 "#b7322a"; }
408
+ multiplayer_user_colors { player_1 "#2456a8"; player_2 "#8a5a00"; player_3 "#1b7142"; player_4 "#b7322a"; player_5 "#2f6b45"; player_6 "#7a3fb8"; player_7 "#0f7b7b"; player_8 "#8f5a00"; player_9 "#1b7a38"; player_10 "#c2332a"; }
409
+ }
410
+ }
411
+ theme_dark "lanes-dark"
412
+ theme_light "lanes-light"
334
413
  web_client {
335
414
  font "monospace"
415
+ theme {
416
+ background "#101318"
417
+ foreground "#edf1f5"
418
+ black "#7d8896"
419
+ red "#f28b7d"
420
+ green "#6fd39a"
421
+ yellow "#e5b84a"
422
+ blue "#7fb3f2"
423
+ magenta "#c9a6ff"
424
+ cyan "#6fd6d0"
425
+ white "#9ca7b5"
426
+ bright_black "#8a95a3"
427
+ bright_red "#ff9d96"
428
+ bright_green "#8fe0a3"
429
+ bright_yellow "#f7cf7f"
430
+ bright_blue "#a5cbff"
431
+ bright_magenta "#dcc6ff"
432
+ bright_cyan "#9ae6e0"
433
+ bright_white "#edf1f5"
434
+ selection_background "#1a2721"
435
+ selection_foreground "#edf1f5"
436
+ }
336
437
  }
337
- osc8_hyperlinks true
338
- theme_dark "tokyo-night"
339
- theme_light "catppuccin-latte"
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
340
449
  default_mode "normal"
341
450
  mouse_mode true
342
451
  copy_clipboard "system"
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@vibelord/lanes",
3
- "version": "1.0.0",
4
- "commit": "a3692b61a114c851b493618bf7f5446b7a84336a",
5
- "digest": "sha256:20b6265bee253def6bce29dbf66e35678e6ecbe9c81c86b03cdf2091660bb5ae",
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,15 +12,19 @@
12
12
  "client/omarchy/discover.sh",
13
13
  "container/lane",
14
14
  "container/lane-run",
15
+ "container/lane-signal",
15
16
  "holders/exec.sh",
17
+ "holders/ssh.sh",
16
18
  "holders/tmux.sh",
17
19
  "holders/zmx.sh",
18
20
  "install.sh",
19
21
  "install/enable-web.sh",
20
22
  "install/host-install.sh",
23
+ "libexec/connection.py",
21
24
  "libexec/sidebar",
22
25
  "libexec/sidebar.py",
23
26
  "libexec/statusd.py",
27
+ "libexec/tokens.py",
24
28
  "libexec/view.sh",
25
29
  "sudo/host-guard.sh",
26
30
  "sudo/one-time-root.sh"