@vibelord/lanes 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/lanes/README.md +37 -7
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +651 -50
- package/payload/lanes/client/ghostty/common.conf +3 -0
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
- package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
- package/payload/lanes/client/ghostty/omarchy.conf +3 -2
- package/payload/lanes/client/lanes-launch +18 -3
- package/payload/lanes/client/lanes-window +17 -1
- package/payload/lanes/container/env.sh +1 -1
- package/payload/lanes/container/hooks/claude-settings.json +10 -0
- package/payload/lanes/container/hooks/codex-hooks.json +22 -0
- package/payload/lanes/container/lane +310 -26
- package/payload/lanes/container/lane-signal +173 -0
- package/payload/lanes/docs/ARCHITECTURE.md +80 -13
- package/payload/lanes/docs/DESIGN.md +182 -4
- package/payload/lanes/docs/RUNBOOK.md +429 -39
- package/payload/lanes/docs/SHARING.md +1 -1
- package/payload/lanes/holders/exec.sh +2 -5
- package/payload/lanes/holders.lock +3 -0
- package/payload/lanes/install/host-install.sh +10 -1
- package/payload/lanes/install/services.py +30 -3
- package/payload/lanes/install.sh +108 -3
- package/payload/lanes/libexec/agent_state.py +32 -0
- package/payload/lanes/libexec/cursor_sync.py +10 -2
- package/payload/lanes/libexec/economy_poll.py +685 -0
- package/payload/lanes/libexec/guard.py +27 -1
- package/payload/lanes/libexec/lanes_lib.py +285 -38
- package/payload/lanes/libexec/notify.py +317 -0
- package/payload/lanes/libexec/registrations.py +1 -1
- package/payload/lanes/libexec/session_actions.py +7 -8
- package/payload/lanes/libexec/sidebar.py +911 -218
- package/payload/lanes/libexec/statusd.py +238 -69
- package/payload/lanes/libexec/tokens.py +38 -7
- package/payload/lanes/libexec/view.sh +13 -3
- package/payload/lanes/systemd/lanes-economy.service +20 -0
- package/payload/lanes/systemd/lanes-notify.service +19 -0
- package/payload/lanes/systemd/lanes-sync.service +3 -0
- package/payload/lanes/web/README.md +83 -4
- package/payload/lanes/web/index.html +2 -1
- package/payload/lanes/web/mobile.css +53 -198
- package/payload/lanes/web/mobile.js +87 -55
- package/payload/lanes/zellij/config.kdl +67 -16
- package/payload/manifest.json +4 -3
- package/payload/lanes/libexec/legacy_sessions.py +0 -130
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
/* Lanes browser shell
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
'
|
|
128
|
-
'
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
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]);
|
|
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.
|
|
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
|
-
|
|
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.
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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"
|
package/payload/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibelord/lanes",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"commit": "
|
|
5
|
-
"digest": "sha256:
|
|
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])
|