@lattices/cli 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -0
- package/app/Lattices.app/Contents/Info.plist +24 -0
- package/app/Package.swift +13 -0
- package/app/Sources/AccessibilityTextExtractor.swift +111 -0
- package/app/Sources/ActionRow.swift +61 -0
- package/app/Sources/App.swift +10 -0
- package/app/Sources/AppDelegate.swift +242 -0
- package/app/Sources/AppShellView.swift +62 -0
- package/app/Sources/AppTypeClassifier.swift +70 -0
- package/app/Sources/AppWindowShell.swift +63 -0
- package/app/Sources/CheatSheetHUD.swift +332 -0
- package/app/Sources/CommandModeState.swift +1362 -0
- package/app/Sources/CommandModeView.swift +1405 -0
- package/app/Sources/CommandModeWindow.swift +192 -0
- package/app/Sources/CommandPaletteView.swift +307 -0
- package/app/Sources/CommandPaletteWindow.swift +134 -0
- package/app/Sources/DaemonProtocol.swift +101 -0
- package/app/Sources/DaemonServer.swift +414 -0
- package/app/Sources/DesktopModel.swift +149 -0
- package/app/Sources/DesktopModelTypes.swift +71 -0
- package/app/Sources/DiagnosticLog.swift +271 -0
- package/app/Sources/EventBus.swift +30 -0
- package/app/Sources/HotkeyManager.swift +254 -0
- package/app/Sources/HotkeyStore.swift +338 -0
- package/app/Sources/InventoryManager.swift +35 -0
- package/app/Sources/InventoryPath.swift +43 -0
- package/app/Sources/KeyRecorderView.swift +210 -0
- package/app/Sources/LatticesApi.swift +1234 -0
- package/app/Sources/LayerBezel.swift +203 -0
- package/app/Sources/MainView.swift +479 -0
- package/app/Sources/MainWindow.swift +83 -0
- package/app/Sources/OcrModel.swift +430 -0
- package/app/Sources/OcrStore.swift +329 -0
- package/app/Sources/OmniSearchState.swift +283 -0
- package/app/Sources/OmniSearchView.swift +288 -0
- package/app/Sources/OmniSearchWindow.swift +105 -0
- package/app/Sources/OrphanRow.swift +129 -0
- package/app/Sources/PaletteCommand.swift +419 -0
- package/app/Sources/PermissionChecker.swift +125 -0
- package/app/Sources/Preferences.swift +99 -0
- package/app/Sources/ProcessModel.swift +199 -0
- package/app/Sources/ProcessQuery.swift +151 -0
- package/app/Sources/Project.swift +28 -0
- package/app/Sources/ProjectRow.swift +368 -0
- package/app/Sources/ProjectScanner.swift +128 -0
- package/app/Sources/ScreenMapState.swift +2387 -0
- package/app/Sources/ScreenMapView.swift +2820 -0
- package/app/Sources/ScreenMapWindowController.swift +89 -0
- package/app/Sources/SessionManager.swift +72 -0
- package/app/Sources/SettingsView.swift +1064 -0
- package/app/Sources/SettingsWindow.swift +20 -0
- package/app/Sources/TabGroupRow.swift +178 -0
- package/app/Sources/Terminal.swift +259 -0
- package/app/Sources/TerminalQuery.swift +156 -0
- package/app/Sources/TerminalSynthesizer.swift +200 -0
- package/app/Sources/Theme.swift +163 -0
- package/app/Sources/TilePickerView.swift +209 -0
- package/app/Sources/TmuxModel.swift +53 -0
- package/app/Sources/TmuxQuery.swift +81 -0
- package/app/Sources/WindowTiler.swift +1778 -0
- package/app/Sources/WorkspaceManager.swift +575 -0
- package/bin/client.js +4 -0
- package/bin/daemon-client.js +187 -0
- package/bin/lattices-app.js +221 -0
- package/bin/lattices.js +1551 -0
- package/docs/api.md +924 -0
- package/docs/app.md +297 -0
- package/docs/concepts.md +135 -0
- package/docs/config.md +245 -0
- package/docs/layers.md +410 -0
- package/docs/ocr.md +185 -0
- package/docs/overview.md +94 -0
- package/docs/quickstart.md +75 -0
- package/package.json +42 -0
package/bin/lattices.js
ADDED
|
@@ -0,0 +1,1551 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { basename, resolve, dirname } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
// Daemon client (lazy-loaded to avoid blocking startup for TTY commands)
|
|
11
|
+
let _daemonClient;
|
|
12
|
+
async function getDaemonClient() {
|
|
13
|
+
if (!_daemonClient) {
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
_daemonClient = await import(resolve(__dirname, "daemon-client.js"));
|
|
16
|
+
}
|
|
17
|
+
return _daemonClient;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const command = args[0];
|
|
22
|
+
|
|
23
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function run(cmd, opts = {}) {
|
|
26
|
+
return execSync(cmd, { encoding: "utf8", ...opts }).trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function runQuiet(cmd) {
|
|
30
|
+
try {
|
|
31
|
+
return run(cmd, { stdio: "pipe" });
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hasTmux() {
|
|
38
|
+
return runQuiet("which tmux") !== null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isInsideTmux() {
|
|
42
|
+
return !!process.env.TMUX;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sessionExists(name) {
|
|
46
|
+
return runQuiet(`tmux has-session -t "${name}" 2>&1`) !== null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pathHash(dir) {
|
|
50
|
+
return createHash("sha256").update(resolve(dir)).digest("hex").slice(0, 6);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toSessionName(dir) {
|
|
54
|
+
const base = basename(dir).replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
55
|
+
return `${base}-${pathHash(dir)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function esc(str) {
|
|
59
|
+
return str.replace(/'/g, "'\\''");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Config ───────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function readConfig(dir) {
|
|
65
|
+
const configPath = resolve(dir, ".lattices.json");
|
|
66
|
+
if (!existsSync(configPath)) return null;
|
|
67
|
+
try {
|
|
68
|
+
const raw = readFileSync(configPath, "utf8");
|
|
69
|
+
return JSON.parse(raw);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
console.warn(`Warning: invalid .lattices.json — ${e.message}`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Workspace config (tab groups) ───────────────────────────────────
|
|
77
|
+
|
|
78
|
+
function readWorkspaceConfig() {
|
|
79
|
+
const configPath = resolve(homedir(), ".lattices", "workspace.json");
|
|
80
|
+
if (!existsSync(configPath)) return null;
|
|
81
|
+
try {
|
|
82
|
+
const raw = readFileSync(configPath, "utf8");
|
|
83
|
+
return JSON.parse(raw);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.warn(`Warning: invalid workspace.json — ${e.message}`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function toGroupSessionName(groupId) {
|
|
91
|
+
return `lattices-group-${groupId}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Get ordered pane IDs for a specific window within a session */
|
|
95
|
+
function getPaneIdsForWindow(sessionName, windowIndex) {
|
|
96
|
+
const out = runQuiet(
|
|
97
|
+
`tmux list-panes -t "${sessionName}:${windowIndex}" -F "#{pane_id}"`
|
|
98
|
+
);
|
|
99
|
+
return out ? out.split("\n").filter(Boolean) : [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Create a tmux window with pane layout for a project dir */
|
|
103
|
+
function createWindowForProject(sessionName, windowIndex, dir, label) {
|
|
104
|
+
const config = readConfig(dir);
|
|
105
|
+
const d = esc(dir);
|
|
106
|
+
|
|
107
|
+
let panes;
|
|
108
|
+
if (config?.panes?.length) {
|
|
109
|
+
panes = resolvePane(config.panes, dir);
|
|
110
|
+
} else {
|
|
111
|
+
panes = defaultPanes(dir);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (windowIndex === 0) {
|
|
115
|
+
// First window already exists from new-session, just set working dir
|
|
116
|
+
run(`tmux send-keys -t "${sessionName}:0" 'cd ${d}' Enter`);
|
|
117
|
+
} else {
|
|
118
|
+
run(`tmux new-window -t "${sessionName}" -c '${d}'`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const winTarget = `${sessionName}:${windowIndex}`;
|
|
122
|
+
|
|
123
|
+
// Rename the window
|
|
124
|
+
const winLabel = label || basename(dir);
|
|
125
|
+
runQuiet(`tmux rename-window -t "${winTarget}" "${winLabel}"`);
|
|
126
|
+
|
|
127
|
+
// Create pane splits
|
|
128
|
+
if (panes.length === 2) {
|
|
129
|
+
const mainSize = panes[0].size || 60;
|
|
130
|
+
run(`tmux split-window -h -t "${winTarget}" -c '${d}' -p ${100 - mainSize}`);
|
|
131
|
+
} else if (panes.length >= 3) {
|
|
132
|
+
const mainSize = panes[0].size || 60;
|
|
133
|
+
for (let i = 1; i < panes.length; i++) {
|
|
134
|
+
run(`tmux split-window -t "${winTarget}" -c '${d}'`);
|
|
135
|
+
}
|
|
136
|
+
runQuiet(`tmux set-option -t "${winTarget}" -w main-pane-width '${mainSize}%'`);
|
|
137
|
+
run(`tmux select-layout -t "${winTarget}" main-vertical`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Get pane IDs and send commands
|
|
141
|
+
const paneIds = getPaneIdsForWindow(sessionName, windowIndex);
|
|
142
|
+
for (let i = 0; i < panes.length && i < paneIds.length; i++) {
|
|
143
|
+
if (panes[i].cmd) {
|
|
144
|
+
run(`tmux send-keys -t "${paneIds[i]}" '${esc(panes[i].cmd)}' Enter`);
|
|
145
|
+
}
|
|
146
|
+
if (panes[i].name) {
|
|
147
|
+
runQuiet(`tmux select-pane -t "${paneIds[i]}" -T "${panes[i].name}"`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Focus first pane in this window
|
|
152
|
+
if (paneIds.length) {
|
|
153
|
+
run(`tmux select-pane -t "${paneIds[0]}"`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Create a group session with one tmux window per tab */
|
|
158
|
+
function createGroupSession(group) {
|
|
159
|
+
const name = toGroupSessionName(group.id);
|
|
160
|
+
const tabs = group.tabs || [];
|
|
161
|
+
|
|
162
|
+
if (!tabs.length) {
|
|
163
|
+
console.log(`Group "${group.id}" has no tabs.`);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Validate all paths exist
|
|
168
|
+
for (const tab of tabs) {
|
|
169
|
+
if (!existsSync(tab.path)) {
|
|
170
|
+
console.log(`Warning: path does not exist — ${tab.path}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const firstDir = esc(tabs[0].path);
|
|
175
|
+
console.log(`Creating group "${group.label || group.id}" (${tabs.length} tabs)...`);
|
|
176
|
+
|
|
177
|
+
// Create session with first window
|
|
178
|
+
run(`tmux new-session -d -s "${name}" -c '${firstDir}'`);
|
|
179
|
+
|
|
180
|
+
// Set up each window/tab
|
|
181
|
+
for (let i = 0; i < tabs.length; i++) {
|
|
182
|
+
const tab = tabs[i];
|
|
183
|
+
const dir = resolve(tab.path);
|
|
184
|
+
createWindowForProject(name, i, dir, tab.label);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Tag the session title
|
|
188
|
+
runQuiet(`tmux set-option -t "${name}" set-titles on`);
|
|
189
|
+
runQuiet(`tmux set-option -t "${name}" set-titles-string "[lattices:${name}] #{window_name} — #{pane_title}"`);
|
|
190
|
+
|
|
191
|
+
// Select first window
|
|
192
|
+
runQuiet(`tmux select-window -t "${name}:0"`);
|
|
193
|
+
|
|
194
|
+
return name;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function listGroups() {
|
|
198
|
+
const ws = readWorkspaceConfig();
|
|
199
|
+
if (!ws?.groups?.length) {
|
|
200
|
+
console.log("No tab groups configured in ~/.lattices/workspace.json");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log("Tab Groups:\n");
|
|
205
|
+
for (const group of ws.groups) {
|
|
206
|
+
const tabs = group.tabs || [];
|
|
207
|
+
const runningCount = tabs.filter((t) => sessionExists(toSessionName(resolve(t.path)))).length;
|
|
208
|
+
const running = runningCount > 0;
|
|
209
|
+
const status = running
|
|
210
|
+
? `\x1b[32m● ${runningCount}/${tabs.length} running\x1b[0m`
|
|
211
|
+
: "\x1b[90m○ stopped\x1b[0m";
|
|
212
|
+
const tabLabels = tabs.map((t) => t.label || basename(t.path)).join(", ");
|
|
213
|
+
console.log(` ${group.label || group.id} ${status}`);
|
|
214
|
+
console.log(` id: ${group.id}`);
|
|
215
|
+
console.log(` tabs: ${tabLabels}`);
|
|
216
|
+
console.log();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function groupCommand(id) {
|
|
221
|
+
const ws = readWorkspaceConfig();
|
|
222
|
+
if (!ws?.groups?.length) {
|
|
223
|
+
console.log("No tab groups configured in ~/.lattices/workspace.json");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!id) {
|
|
228
|
+
listGroups();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const group = ws.groups.find((g) => g.id === id);
|
|
233
|
+
if (!group) {
|
|
234
|
+
console.log(`No group "${id}". Available: ${ws.groups.map((g) => g.id).join(", ")}`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const tabs = group.tabs || [];
|
|
239
|
+
if (!tabs.length) {
|
|
240
|
+
console.log(`Group "${group.id}" has no tabs.`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Each tab gets its own lattices session (individual project sessions)
|
|
245
|
+
const firstDir = resolve(tabs[0].path);
|
|
246
|
+
const firstName = toSessionName(firstDir);
|
|
247
|
+
|
|
248
|
+
// If the first tab's session already exists, just attach
|
|
249
|
+
if (sessionExists(firstName)) {
|
|
250
|
+
console.log(`Reattaching to "${group.label || group.id}" (${tabs[0].label || basename(firstDir)})...`);
|
|
251
|
+
attach(firstName);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Create a detached session for each tab
|
|
256
|
+
console.log(`Launching group "${group.label || group.id}" (${tabs.length} tabs)...`);
|
|
257
|
+
for (const tab of tabs) {
|
|
258
|
+
const dir = resolve(tab.path);
|
|
259
|
+
const name = toSessionName(dir);
|
|
260
|
+
if (!sessionExists(name)) {
|
|
261
|
+
console.log(` Creating session: ${tab.label || basename(dir)}`);
|
|
262
|
+
createSession(dir);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Attach to the first tab's session
|
|
267
|
+
attach(firstName);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function tabCommand(groupId, tabName) {
|
|
271
|
+
if (!groupId) {
|
|
272
|
+
console.log("Usage: lattices tab <group-id> <tab-name|index>");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const ws = readWorkspaceConfig();
|
|
277
|
+
if (!ws?.groups?.length) {
|
|
278
|
+
console.log("No tab groups configured.");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const group = ws.groups.find((g) => g.id === groupId);
|
|
283
|
+
if (!group) {
|
|
284
|
+
console.log(`No group "${groupId}".`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const tabs = group.tabs || [];
|
|
289
|
+
|
|
290
|
+
if (!tabName) {
|
|
291
|
+
// List tabs with their session status
|
|
292
|
+
console.log(`Tabs in "${group.label || group.id}":\n`);
|
|
293
|
+
for (let i = 0; i < tabs.length; i++) {
|
|
294
|
+
const label = tabs[i].label || basename(tabs[i].path);
|
|
295
|
+
const tabSession = toSessionName(resolve(tabs[i].path));
|
|
296
|
+
const running = sessionExists(tabSession);
|
|
297
|
+
const status = running ? "\x1b[32m●\x1b[0m" : "\x1b[90m○\x1b[0m";
|
|
298
|
+
console.log(` ${status} ${i}: ${label} (session: ${tabSession})`);
|
|
299
|
+
}
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Resolve tab target to an index
|
|
304
|
+
let tabIdx;
|
|
305
|
+
if (/^\d+$/.test(tabName)) {
|
|
306
|
+
tabIdx = parseInt(tabName, 10);
|
|
307
|
+
} else {
|
|
308
|
+
tabIdx = tabs.findIndex(
|
|
309
|
+
(t) => (t.label || basename(t.path)).toLowerCase() === tabName.toLowerCase()
|
|
310
|
+
);
|
|
311
|
+
if (tabIdx === -1) {
|
|
312
|
+
const available = tabs.map((t) => t.label || basename(t.path)).join(", ");
|
|
313
|
+
console.log(`No tab "${tabName}". Available: ${available}`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (tabIdx < 0 || tabIdx >= tabs.length) {
|
|
319
|
+
console.log(`Tab index ${tabIdx} is out of range (${tabs.length} tabs).`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Each tab is its own lattices session — attach to it
|
|
324
|
+
const dir = resolve(tabs[tabIdx].path);
|
|
325
|
+
const tabSession = toSessionName(dir);
|
|
326
|
+
const label = tabs[tabIdx].label || basename(dir);
|
|
327
|
+
|
|
328
|
+
if (sessionExists(tabSession)) {
|
|
329
|
+
console.log(`Attaching to tab: ${label}`);
|
|
330
|
+
attach(tabSession);
|
|
331
|
+
} else {
|
|
332
|
+
console.log(`Creating session for tab: ${label}`);
|
|
333
|
+
createSession(dir);
|
|
334
|
+
attach(tabSession);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Detect dev command ───────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
function detectPackageManager(dir) {
|
|
341
|
+
if (existsSync(resolve(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
342
|
+
if (existsSync(resolve(dir, "bun.lockb")) || existsSync(resolve(dir, "bun.lock")))
|
|
343
|
+
return "bun";
|
|
344
|
+
if (existsSync(resolve(dir, "yarn.lock"))) return "yarn";
|
|
345
|
+
return "npm";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function detectDevCommand(dir) {
|
|
349
|
+
const pkgPath = resolve(dir, "package.json");
|
|
350
|
+
if (!existsSync(pkgPath)) return null;
|
|
351
|
+
|
|
352
|
+
let pkg;
|
|
353
|
+
try {
|
|
354
|
+
pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
355
|
+
} catch {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const scripts = pkg.scripts || {};
|
|
360
|
+
const pm = detectPackageManager(dir);
|
|
361
|
+
const run = pm === "npm" ? "npm run" : pm;
|
|
362
|
+
|
|
363
|
+
if (scripts.dev) return `${run} dev`;
|
|
364
|
+
if (scripts.start) return `${run} start`;
|
|
365
|
+
if (scripts.serve) return `${run} serve`;
|
|
366
|
+
if (scripts.watch) return `${run} watch`;
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Session creation ─────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
function resolvePane(panes, dir) {
|
|
373
|
+
return panes.map((p) => ({
|
|
374
|
+
name: p.name || "",
|
|
375
|
+
cmd: p.cmd || undefined,
|
|
376
|
+
size: p.size || undefined,
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Get ordered pane IDs (e.g. ["%0", "%1"]) for a session */
|
|
381
|
+
function getPaneIds(name) {
|
|
382
|
+
const out = runQuiet(
|
|
383
|
+
`tmux list-panes -t "${name}" -F "#{pane_id}"`
|
|
384
|
+
);
|
|
385
|
+
return out ? out.split("\n").filter(Boolean) : [];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function createSession(dir) {
|
|
389
|
+
const name = toSessionName(dir);
|
|
390
|
+
const config = readConfig(dir);
|
|
391
|
+
const d = esc(dir);
|
|
392
|
+
|
|
393
|
+
let panes;
|
|
394
|
+
if (config?.panes?.length) {
|
|
395
|
+
panes = resolvePane(config.panes, dir);
|
|
396
|
+
console.log(`Using .lattices.json (${panes.length} panes)`);
|
|
397
|
+
} else {
|
|
398
|
+
panes = defaultPanes(dir);
|
|
399
|
+
if (panes.length > 1) console.log(`Detected: ${panes[1].cmd}`);
|
|
400
|
+
else console.log(`No dev server detected — single pane`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Create session (targets are config-agnostic — no hardcoded indices)
|
|
404
|
+
run(`tmux new-session -d -s "${name}" -c '${d}'`);
|
|
405
|
+
|
|
406
|
+
if (panes.length === 2) {
|
|
407
|
+
const mainSize = panes[0].size || 60;
|
|
408
|
+
run(
|
|
409
|
+
`tmux split-window -h -t "${name}" -c '${d}' -p ${100 - mainSize}`
|
|
410
|
+
);
|
|
411
|
+
} else if (panes.length >= 3) {
|
|
412
|
+
const mainSize = panes[0].size || 60;
|
|
413
|
+
for (let i = 1; i < panes.length; i++) {
|
|
414
|
+
run(`tmux split-window -t "${name}" -c '${d}'`);
|
|
415
|
+
}
|
|
416
|
+
runQuiet(
|
|
417
|
+
`tmux set-option -t "${name}" -w main-pane-width '${mainSize}%'`
|
|
418
|
+
);
|
|
419
|
+
run(`tmux select-layout -t "${name}" main-vertical`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Get actual pane IDs (works regardless of base-index / pane-base-index)
|
|
423
|
+
const paneIds = getPaneIds(name);
|
|
424
|
+
|
|
425
|
+
// Send commands and name each pane
|
|
426
|
+
for (let i = 0; i < panes.length && i < paneIds.length; i++) {
|
|
427
|
+
if (panes[i].cmd) {
|
|
428
|
+
run(`tmux send-keys -t "${paneIds[i]}" '${esc(panes[i].cmd)}' Enter`);
|
|
429
|
+
}
|
|
430
|
+
if (panes[i].name) {
|
|
431
|
+
runQuiet(`tmux select-pane -t "${paneIds[i]}" -T "${panes[i].name}"`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Tag the terminal window title so the menu bar app can find it
|
|
436
|
+
// Format: [lattices:session-hash] pane_title: current_command
|
|
437
|
+
runQuiet(`tmux set-option -t "${name}" set-titles on`);
|
|
438
|
+
runQuiet(`tmux set-option -t "${name}" set-titles-string "[lattices:${name}] #{pane_title}"`);
|
|
439
|
+
|
|
440
|
+
// Name the tmux window after the project and focus the first pane
|
|
441
|
+
runQuiet(`tmux rename-window -t "${name}" "${basename(dir)}"`);
|
|
442
|
+
if (paneIds.length) {
|
|
443
|
+
run(`tmux select-pane -t "${paneIds[0]}"`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return name;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Check each pane and prefill or restart commands that have exited.
|
|
450
|
+
* mode: "prefill" types the command without pressing Enter
|
|
451
|
+
* mode: "ensure" types the command and presses Enter */
|
|
452
|
+
function restoreCommands(name, dir, mode) {
|
|
453
|
+
const config = readConfig(dir);
|
|
454
|
+
let panes;
|
|
455
|
+
if (config?.panes?.length) {
|
|
456
|
+
panes = resolvePane(config.panes, dir);
|
|
457
|
+
} else {
|
|
458
|
+
panes = defaultPanes(dir);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const paneIds = getPaneIds(name);
|
|
462
|
+
const shells = new Set(["bash", "zsh", "fish", "sh", "dash"]);
|
|
463
|
+
|
|
464
|
+
let count = 0;
|
|
465
|
+
for (let i = 0; i < panes.length && i < paneIds.length; i++) {
|
|
466
|
+
if (!panes[i].cmd) continue;
|
|
467
|
+
const cur = runQuiet(
|
|
468
|
+
`tmux display-message -t "${paneIds[i]}" -p "#{pane_current_command}"`
|
|
469
|
+
);
|
|
470
|
+
if (cur && shells.has(cur)) {
|
|
471
|
+
if (mode === "ensure") {
|
|
472
|
+
run(`tmux send-keys -t "${paneIds[i]}" '${esc(panes[i].cmd)}' Enter`);
|
|
473
|
+
} else {
|
|
474
|
+
run(`tmux send-keys -t "${paneIds[i]}" '${esc(panes[i].cmd)}'`);
|
|
475
|
+
}
|
|
476
|
+
count++;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (count > 0) {
|
|
480
|
+
const verb = mode === "ensure" ? "Restarted" : "Prefilled";
|
|
481
|
+
console.log(`${verb} ${count} exited command${count > 1 ? "s" : ""}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── Sync / reconcile ────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
function resolvePanes(dir) {
|
|
488
|
+
const config = readConfig(dir);
|
|
489
|
+
if (config?.panes?.length) {
|
|
490
|
+
return resolvePane(config.panes, dir);
|
|
491
|
+
}
|
|
492
|
+
return defaultPanes(dir);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function defaultPanes(dir) {
|
|
496
|
+
const devCmd = detectDevCommand(dir);
|
|
497
|
+
if (devCmd) {
|
|
498
|
+
return [
|
|
499
|
+
{ name: "claude", cmd: "claude", size: 60 },
|
|
500
|
+
{ name: "server", cmd: devCmd },
|
|
501
|
+
];
|
|
502
|
+
}
|
|
503
|
+
// No dev server detected → single pane
|
|
504
|
+
return [{ name: "claude", cmd: "claude" }];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function syncSession() {
|
|
508
|
+
const dir = process.cwd();
|
|
509
|
+
const name = toSessionName(dir);
|
|
510
|
+
|
|
511
|
+
if (!sessionExists(name)) {
|
|
512
|
+
console.log(`No session "${name}" — creating from scratch.`);
|
|
513
|
+
createSession(dir);
|
|
514
|
+
console.log("Session created.");
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const panes = resolvePanes(dir);
|
|
519
|
+
const actualIds = getPaneIds(name);
|
|
520
|
+
const declared = panes.length;
|
|
521
|
+
const actual = actualIds.length;
|
|
522
|
+
const d = esc(dir);
|
|
523
|
+
const shells = new Set(["bash", "zsh", "fish", "sh", "dash"]);
|
|
524
|
+
|
|
525
|
+
console.log(`Session "${name}": ${actual} pane(s) found, ${declared} declared.`);
|
|
526
|
+
|
|
527
|
+
// Phase 1: recreate missing panes
|
|
528
|
+
if (actual < declared) {
|
|
529
|
+
const missing = declared - actual;
|
|
530
|
+
console.log(`Recreating ${missing} missing pane(s)...`);
|
|
531
|
+
for (let i = 0; i < missing; i++) {
|
|
532
|
+
run(`tmux split-window -t "${name}" -c '${d}'`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Re-apply layout
|
|
536
|
+
if (declared === 2) {
|
|
537
|
+
const mainSize = panes[0].size || 60;
|
|
538
|
+
// With 2 panes, use horizontal split layout
|
|
539
|
+
run(`tmux select-layout -t "${name}" even-horizontal`);
|
|
540
|
+
runQuiet(
|
|
541
|
+
`tmux set-option -t "${name}" -w main-pane-width '${mainSize}%'`
|
|
542
|
+
);
|
|
543
|
+
run(`tmux select-layout -t "${name}" main-vertical`);
|
|
544
|
+
} else if (declared >= 3) {
|
|
545
|
+
const mainSize = panes[0].size || 60;
|
|
546
|
+
runQuiet(
|
|
547
|
+
`tmux set-option -t "${name}" -w main-pane-width '${mainSize}%'`
|
|
548
|
+
);
|
|
549
|
+
run(`tmux select-layout -t "${name}" main-vertical`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Phase 2: restore commands and labels on all panes
|
|
554
|
+
const freshIds = getPaneIds(name);
|
|
555
|
+
let restored = 0;
|
|
556
|
+
for (let i = 0; i < panes.length && i < freshIds.length; i++) {
|
|
557
|
+
// Set pane title/label
|
|
558
|
+
if (panes[i].name) {
|
|
559
|
+
runQuiet(`tmux select-pane -t "${freshIds[i]}" -T "${panes[i].name}"`);
|
|
560
|
+
}
|
|
561
|
+
// If pane is idle at a shell prompt, send its declared command
|
|
562
|
+
if (panes[i].cmd) {
|
|
563
|
+
const cur = runQuiet(
|
|
564
|
+
`tmux display-message -t "${freshIds[i]}" -p "#{pane_current_command}"`
|
|
565
|
+
);
|
|
566
|
+
if (cur && shells.has(cur)) {
|
|
567
|
+
run(`tmux send-keys -t "${freshIds[i]}" '${esc(panes[i].cmd)}' Enter`);
|
|
568
|
+
restored++;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Focus first pane
|
|
574
|
+
if (freshIds.length) {
|
|
575
|
+
run(`tmux select-pane -t "${freshIds[0]}"`);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (restored > 0) {
|
|
579
|
+
console.log(`Restarted ${restored} command(s).`);
|
|
580
|
+
}
|
|
581
|
+
console.log("Sync complete.");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ── Restart pane ────────────────────────────────────────────────────
|
|
585
|
+
|
|
586
|
+
function restartPane(target) {
|
|
587
|
+
const dir = process.cwd();
|
|
588
|
+
const name = toSessionName(dir);
|
|
589
|
+
|
|
590
|
+
if (!sessionExists(name)) {
|
|
591
|
+
console.log(`No session "${name}".`);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const panes = resolvePanes(dir);
|
|
596
|
+
const paneIds = getPaneIds(name);
|
|
597
|
+
|
|
598
|
+
// Resolve target to an index
|
|
599
|
+
let idx;
|
|
600
|
+
if (target === undefined || target === null || target === "") {
|
|
601
|
+
// Default: first pane (claude)
|
|
602
|
+
idx = 0;
|
|
603
|
+
} else if (/^\d+$/.test(target)) {
|
|
604
|
+
idx = parseInt(target, 10);
|
|
605
|
+
} else {
|
|
606
|
+
// Match by name (case-insensitive)
|
|
607
|
+
idx = panes.findIndex(
|
|
608
|
+
(p) => p.name && p.name.toLowerCase() === target.toLowerCase()
|
|
609
|
+
);
|
|
610
|
+
if (idx === -1) {
|
|
611
|
+
console.log(
|
|
612
|
+
`No pane named "${target}". Available: ${panes.map((p, i) => p.name || `[${i}]`).join(", ")}`
|
|
613
|
+
);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (idx < 0 || idx >= paneIds.length) {
|
|
619
|
+
console.log(`Pane index ${idx} is out of range (${paneIds.length} panes).`);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const paneId = paneIds[idx];
|
|
624
|
+
const pane = panes[idx] || {};
|
|
625
|
+
const label = pane.name || `pane ${idx}`;
|
|
626
|
+
|
|
627
|
+
// Get the PID of the process running in the pane
|
|
628
|
+
const panePid = runQuiet(
|
|
629
|
+
`tmux display-message -t "${paneId}" -p "#{pane_pid}"`
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
// Step 1: try C-c to gracefully stop
|
|
633
|
+
console.log(`Stopping ${label}...`);
|
|
634
|
+
run(`tmux send-keys -t "${paneId}" C-c`);
|
|
635
|
+
|
|
636
|
+
// Brief pause to let C-c propagate
|
|
637
|
+
execSync("sleep 0.5");
|
|
638
|
+
|
|
639
|
+
// Step 2: check if the process is still running (not back to shell)
|
|
640
|
+
const shells = new Set(["bash", "zsh", "fish", "sh", "dash"]);
|
|
641
|
+
const cur = runQuiet(
|
|
642
|
+
`tmux display-message -t "${paneId}" -p "#{pane_current_command}"`
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
if (cur && !shells.has(cur)) {
|
|
646
|
+
// Still hung — escalate: kill the child processes of the pane
|
|
647
|
+
console.log(`Process still running (${cur}), sending SIGKILL...`);
|
|
648
|
+
if (panePid) {
|
|
649
|
+
// Kill all children of the pane's shell process
|
|
650
|
+
runQuiet(`pkill -KILL -P ${panePid}`);
|
|
651
|
+
execSync("sleep 0.3");
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Step 3: send the declared command
|
|
656
|
+
if (pane.cmd) {
|
|
657
|
+
console.log(`Starting: ${pane.cmd}`);
|
|
658
|
+
run(`tmux send-keys -t "${paneId}" '${esc(pane.cmd)}' Enter`);
|
|
659
|
+
} else {
|
|
660
|
+
console.log(`No command declared for ${label} — pane is at shell prompt.`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ── Commands ─────────────────────────────────────────────────────────
|
|
665
|
+
|
|
666
|
+
// ── Daemon-aware commands ────────────────────────────────────────────
|
|
667
|
+
|
|
668
|
+
async function daemonStatusCommand() {
|
|
669
|
+
try {
|
|
670
|
+
const { daemonCall } = await getDaemonClient();
|
|
671
|
+
const status = await daemonCall("daemon.status");
|
|
672
|
+
const uptime = Math.round(status.uptime);
|
|
673
|
+
const h = Math.floor(uptime / 3600);
|
|
674
|
+
const m = Math.floor((uptime % 3600) / 60);
|
|
675
|
+
const s = uptime % 60;
|
|
676
|
+
const uptimeStr = h > 0 ? `${h}h ${m}m ${s}s` : m > 0 ? `${m}m ${s}s` : `${s}s`;
|
|
677
|
+
console.log(`\x1b[32m●\x1b[0m Daemon running on ws://127.0.0.1:9399`);
|
|
678
|
+
console.log(` uptime: ${uptimeStr}`);
|
|
679
|
+
console.log(` clients: ${status.clientCount}`);
|
|
680
|
+
console.log(` windows: ${status.windowCount}`);
|
|
681
|
+
console.log(` tmux: ${status.tmuxSessionCount} sessions`);
|
|
682
|
+
console.log(` version: ${status.version}`);
|
|
683
|
+
} catch {
|
|
684
|
+
console.log("\x1b[90m○\x1b[0m Daemon not running (start with: lattices app)");
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function windowsCommand(jsonFlag) {
|
|
689
|
+
try {
|
|
690
|
+
const { daemonCall } = await getDaemonClient();
|
|
691
|
+
const windows = await daemonCall("windows.list");
|
|
692
|
+
if (jsonFlag) {
|
|
693
|
+
console.log(JSON.stringify(windows, null, 2));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (!windows.length) {
|
|
697
|
+
console.log("No windows tracked.");
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
console.log(`Windows (${windows.length}):\n`);
|
|
701
|
+
for (const w of windows) {
|
|
702
|
+
const session = w.latticesSession ? ` \x1b[36m[lattices:${w.latticesSession}]\x1b[0m` : "";
|
|
703
|
+
const layer = w.layerTag ? ` \x1b[33m[layer:${w.layerTag}]\x1b[0m` : "";
|
|
704
|
+
const spaces = w.spaceIds.length ? ` space:${w.spaceIds.join(",")}` : "";
|
|
705
|
+
console.log(` \x1b[1m${w.app}\x1b[0m wid:${w.wid}${spaces}${session}${layer}`);
|
|
706
|
+
console.log(` "${w.title}"`);
|
|
707
|
+
console.log(` ${Math.round(w.frame.w)}×${Math.round(w.frame.h)} at (${Math.round(w.frame.x)},${Math.round(w.frame.y)})`);
|
|
708
|
+
console.log();
|
|
709
|
+
}
|
|
710
|
+
} catch {
|
|
711
|
+
console.log("Daemon not running. Start with: lattices app");
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function windowAssignCommand(wid, layerId) {
|
|
716
|
+
if (!wid || !layerId) {
|
|
717
|
+
console.log("Usage: lattices window assign <wid> <layer-id>");
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
try {
|
|
721
|
+
const { daemonCall } = await getDaemonClient();
|
|
722
|
+
await daemonCall("window.assignLayer", { wid: parseInt(wid), layer: layerId });
|
|
723
|
+
console.log(`Tagged wid:${wid} → layer:${layerId}`);
|
|
724
|
+
} catch (e) {
|
|
725
|
+
console.log(`Error: ${e.message}`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async function windowLayerMapCommand(jsonFlag) {
|
|
730
|
+
try {
|
|
731
|
+
const { daemonCall } = await getDaemonClient();
|
|
732
|
+
const map = await daemonCall("window.layerMap");
|
|
733
|
+
if (jsonFlag) {
|
|
734
|
+
console.log(JSON.stringify(map, null, 2));
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const entries = Object.entries(map);
|
|
738
|
+
if (!entries.length) {
|
|
739
|
+
console.log("No layer tags assigned.");
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
console.log("Window → Layer map:\n");
|
|
743
|
+
for (const [wid, layer] of entries) {
|
|
744
|
+
console.log(` wid:${wid} → ${layer}`);
|
|
745
|
+
}
|
|
746
|
+
} catch {
|
|
747
|
+
console.log("Daemon not running. Start with: lattices app");
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function focusCommand(session) {
|
|
752
|
+
if (!session) {
|
|
753
|
+
console.log("Usage: lattices focus <session-name>");
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
try {
|
|
757
|
+
const { daemonCall } = await getDaemonClient();
|
|
758
|
+
await daemonCall("window.focus", { session });
|
|
759
|
+
console.log(`Focused: ${session}`);
|
|
760
|
+
} catch (e) {
|
|
761
|
+
console.log(`Error: ${e.message}`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function layerCommand(index) {
|
|
766
|
+
try {
|
|
767
|
+
const { daemonCall } = await getDaemonClient();
|
|
768
|
+
if (index === undefined || index === null || index === "") {
|
|
769
|
+
const result = await daemonCall("layers.list");
|
|
770
|
+
if (!result.layers.length) {
|
|
771
|
+
console.log("No layers configured.");
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
console.log("Layers:\n");
|
|
775
|
+
for (const layer of result.layers) {
|
|
776
|
+
const active = layer.index === result.active ? " \x1b[32m● active\x1b[0m" : "";
|
|
777
|
+
console.log(` [${layer.index}] ${layer.label} (${layer.projectCount} projects)${active}`);
|
|
778
|
+
}
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const idx = parseInt(index, 10);
|
|
782
|
+
if (!isNaN(idx)) {
|
|
783
|
+
await daemonCall("layer.switch", { index: idx });
|
|
784
|
+
console.log(`Switched to layer ${idx}`);
|
|
785
|
+
} else {
|
|
786
|
+
await daemonCall("layer.switch", { name: index });
|
|
787
|
+
console.log(`Switched to layer "${index}"`);
|
|
788
|
+
}
|
|
789
|
+
} catch (e) {
|
|
790
|
+
console.log(`Error: ${e.message}`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
async function diagCommand(limit) {
|
|
795
|
+
try {
|
|
796
|
+
const { daemonCall } = await getDaemonClient();
|
|
797
|
+
const result = await daemonCall("diagnostics.list", { limit: parseInt(limit, 10) || 40 });
|
|
798
|
+
if (!result.entries || !result.entries.length) {
|
|
799
|
+
console.log("No diagnostic entries.");
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
for (const entry of result.entries) {
|
|
803
|
+
const icon = entry.level === "success" ? "\x1b[32m✓\x1b[0m" :
|
|
804
|
+
entry.level === "warning" ? "\x1b[33m⚠\x1b[0m" :
|
|
805
|
+
entry.level === "error" ? "\x1b[31m✗\x1b[0m" : "›";
|
|
806
|
+
console.log(` \x1b[90m${entry.time}\x1b[0m ${icon} ${entry.message}`);
|
|
807
|
+
}
|
|
808
|
+
} catch (e) {
|
|
809
|
+
console.log(`Error: ${e.message}`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function distributeCommand() {
|
|
814
|
+
try {
|
|
815
|
+
const { daemonCall } = await getDaemonClient();
|
|
816
|
+
await daemonCall("layout.distribute");
|
|
817
|
+
console.log("Distributed visible windows into grid");
|
|
818
|
+
} catch {
|
|
819
|
+
console.log("Daemon not running. Start with: lattices app");
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function daemonLsCommand() {
|
|
824
|
+
try {
|
|
825
|
+
const { daemonCall, isDaemonRunning } = await getDaemonClient();
|
|
826
|
+
if (!(await isDaemonRunning())) return false;
|
|
827
|
+
const sessions = await daemonCall("tmux.sessions");
|
|
828
|
+
if (!sessions.length) {
|
|
829
|
+
console.log("No active tmux sessions.");
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Annotate sessions with workspace group info
|
|
834
|
+
const ws = readWorkspaceConfig();
|
|
835
|
+
const sessionGroupMap = new Map();
|
|
836
|
+
if (ws?.groups) {
|
|
837
|
+
for (const g of ws.groups) {
|
|
838
|
+
for (const tab of g.tabs || []) {
|
|
839
|
+
const tabSession = toSessionName(resolve(tab.path));
|
|
840
|
+
sessionGroupMap.set(tabSession, {
|
|
841
|
+
group: g.label || g.id,
|
|
842
|
+
tab: tab.label || basename(tab.path),
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
console.log("Sessions:\n");
|
|
849
|
+
for (const s of sessions) {
|
|
850
|
+
const info = sessionGroupMap.get(s.name);
|
|
851
|
+
const groupTag = info ? ` \x1b[36m[${info.group}: ${info.tab}]\x1b[0m` : "";
|
|
852
|
+
const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
|
|
853
|
+
console.log(` ${s.name} (${s.windowCount} windows)${attachTag}${groupTag}`);
|
|
854
|
+
}
|
|
855
|
+
return true;
|
|
856
|
+
} catch {
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async function daemonStatusInventory() {
|
|
862
|
+
try {
|
|
863
|
+
const { daemonCall, isDaemonRunning } = await getDaemonClient();
|
|
864
|
+
if (!(await isDaemonRunning())) return false;
|
|
865
|
+
const inv = await daemonCall("tmux.inventory");
|
|
866
|
+
|
|
867
|
+
// Build managed session name set
|
|
868
|
+
const managed = new Map();
|
|
869
|
+
const ws = readWorkspaceConfig();
|
|
870
|
+
if (ws?.groups) {
|
|
871
|
+
for (const g of ws.groups) {
|
|
872
|
+
for (const tab of g.tabs || []) {
|
|
873
|
+
const name = toSessionName(resolve(tab.path));
|
|
874
|
+
const label = `${g.label || g.id}: ${tab.label || basename(tab.path)}`;
|
|
875
|
+
managed.set(name, label);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
for (const s of inv.all) {
|
|
880
|
+
if (!managed.has(s.name)) {
|
|
881
|
+
// Check if it matches a scanned project (via daemon)
|
|
882
|
+
const projects = await daemonCall("projects.list");
|
|
883
|
+
for (const p of projects) {
|
|
884
|
+
managed.set(p.sessionName, p.name);
|
|
885
|
+
}
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const managedSessions = inv.all.filter((s) => managed.has(s.name));
|
|
891
|
+
const orphanSessions = inv.orphans;
|
|
892
|
+
|
|
893
|
+
if (managedSessions.length > 0) {
|
|
894
|
+
console.log(`\x1b[32m●\x1b[0m Managed Sessions (${managedSessions.length})\n`);
|
|
895
|
+
for (const s of managedSessions) {
|
|
896
|
+
const label = managed.get(s.name) || s.name;
|
|
897
|
+
const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
|
|
898
|
+
console.log(` \x1b[1m${s.name}\x1b[0m (${s.windowCount} window${s.windowCount === 1 ? "" : "s"})${attachTag} \x1b[36m[${label}]\x1b[0m`);
|
|
899
|
+
for (const p of s.panes) {
|
|
900
|
+
console.log(` ${p.title || "pane"}: ${p.currentCommand}`);
|
|
901
|
+
}
|
|
902
|
+
console.log();
|
|
903
|
+
}
|
|
904
|
+
} else {
|
|
905
|
+
console.log("\x1b[90m○\x1b[0m No managed sessions running.\n");
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (orphanSessions.length > 0) {
|
|
909
|
+
console.log(`\x1b[33m○\x1b[0m Unmanaged Sessions (${orphanSessions.length})\n`);
|
|
910
|
+
for (const s of orphanSessions) {
|
|
911
|
+
const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
|
|
912
|
+
console.log(` \x1b[1m${s.name}\x1b[0m (${s.windowCount} window${s.windowCount === 1 ? "" : "s"})${attachTag}`);
|
|
913
|
+
for (const p of s.panes) {
|
|
914
|
+
console.log(` ${p.title || "pane"}: ${p.currentCommand}`);
|
|
915
|
+
}
|
|
916
|
+
console.log();
|
|
917
|
+
}
|
|
918
|
+
} else {
|
|
919
|
+
console.log("\x1b[90m○\x1b[0m No unmanaged sessions.\n");
|
|
920
|
+
}
|
|
921
|
+
return true;
|
|
922
|
+
} catch {
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ── OCR commands ──────────────────────────────────────────────────────
|
|
928
|
+
|
|
929
|
+
async function scanCommand(sub, ...rest) {
|
|
930
|
+
const { daemonCall } = await getDaemonClient();
|
|
931
|
+
|
|
932
|
+
if (!sub || sub === "snapshot" || sub === "ls" || sub === "--full" || sub === "-f" || sub === "--json") {
|
|
933
|
+
const full = sub === "--full" || sub === "-f" || rest.includes("--full") || rest.includes("-f");
|
|
934
|
+
const json = sub === "--json" || rest.includes("--json");
|
|
935
|
+
try {
|
|
936
|
+
const results = await daemonCall("ocr.snapshot", null, 5000);
|
|
937
|
+
if (!results.length) {
|
|
938
|
+
console.log("No scan results yet. The first scan runs ~60s after launch.");
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (json) {
|
|
942
|
+
console.log(JSON.stringify(results, null, 2));
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
console.log(`\x1b[1mScan\x1b[0m (${results.length} windows)\n`);
|
|
946
|
+
for (const r of results) {
|
|
947
|
+
const age = Math.round((Date.now() / 1000) - r.timestamp);
|
|
948
|
+
const ageStr = age < 60 ? `${age}s ago` : age < 3600 ? `${Math.floor(age / 60)}m ago` : `${Math.floor(age / 3600)}h ago`;
|
|
949
|
+
const src = r.source === "accessibility" ? "\x1b[33mAX\x1b[0m" : "\x1b[35mOCR\x1b[0m";
|
|
950
|
+
const lines = (r.fullText || "").split("\n").filter(Boolean);
|
|
951
|
+
console.log(` \x1b[1m${r.app}\x1b[0m wid:${r.wid} ${src} \x1b[90m${ageStr}\x1b[0m`);
|
|
952
|
+
console.log(` \x1b[36m"${r.title || "(untitled)"}"\x1b[0m`);
|
|
953
|
+
if (lines.length) {
|
|
954
|
+
if (full) {
|
|
955
|
+
for (const line of lines) {
|
|
956
|
+
console.log(` \x1b[90m${line}\x1b[0m`);
|
|
957
|
+
}
|
|
958
|
+
} else {
|
|
959
|
+
const maxPreview = 5;
|
|
960
|
+
const preview = lines.slice(0, maxPreview).map(l => l.length > 100 ? l.slice(0, 97) + "..." : l);
|
|
961
|
+
for (const line of preview) {
|
|
962
|
+
console.log(` \x1b[90m${line}\x1b[0m`);
|
|
963
|
+
}
|
|
964
|
+
if (lines.length > maxPreview) {
|
|
965
|
+
console.log(` \x1b[90m… ${lines.length - maxPreview} more lines\x1b[0m`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
} else {
|
|
969
|
+
console.log(` \x1b[90m(no text detected)\x1b[0m`);
|
|
970
|
+
}
|
|
971
|
+
console.log();
|
|
972
|
+
}
|
|
973
|
+
} catch {
|
|
974
|
+
console.log("Daemon not running. Start with: lattices app");
|
|
975
|
+
}
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (sub === "search") {
|
|
980
|
+
const query = rest.join(" ");
|
|
981
|
+
if (!query) {
|
|
982
|
+
console.log("Usage: lattices scan search <query>");
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
try {
|
|
986
|
+
const results = await daemonCall("ocr.search", { query }, 5000);
|
|
987
|
+
if (!results.length) {
|
|
988
|
+
console.log(`No matches for "${query}".`);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
console.log(`\x1b[1mSearch\x1b[0m "${query}" (${results.length} matches)\n`);
|
|
992
|
+
for (const r of results) {
|
|
993
|
+
const snippet = r.snippet || r.fullText?.slice(0, 120) || "";
|
|
994
|
+
const src = r.source === "accessibility" ? "\x1b[33mAX\x1b[0m" : "\x1b[35mOCR\x1b[0m";
|
|
995
|
+
console.log(` ${src} \x1b[1m${r.app}\x1b[0m wid:${r.wid}`);
|
|
996
|
+
console.log(` \x1b[36m"${r.title || "(untitled)"}"\x1b[0m`);
|
|
997
|
+
console.log(` ${snippet}`);
|
|
998
|
+
console.log();
|
|
999
|
+
}
|
|
1000
|
+
} catch (e) {
|
|
1001
|
+
console.log(`Error: ${e.message}`);
|
|
1002
|
+
}
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (sub === "recent" || sub === "log") {
|
|
1007
|
+
const full = rest.includes("--full") || rest.includes("-f");
|
|
1008
|
+
const numArg = rest.find(a => !a.startsWith("-"));
|
|
1009
|
+
const limit = parseInt(numArg, 10) || 20;
|
|
1010
|
+
try {
|
|
1011
|
+
const results = await daemonCall("ocr.recent", { limit }, 5000);
|
|
1012
|
+
if (!results.length) {
|
|
1013
|
+
console.log("No history yet. The first scan runs ~60s after launch.");
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
console.log(`\x1b[1mRecent\x1b[0m (${results.length} entries)\n`);
|
|
1017
|
+
for (const r of results) {
|
|
1018
|
+
const ts = new Date(r.timestamp * 1000).toLocaleTimeString();
|
|
1019
|
+
const src = r.source === "accessibility" ? "\x1b[33mAX\x1b[0m" : "\x1b[35mOCR\x1b[0m";
|
|
1020
|
+
const lines = (r.fullText || "").split("\n").filter(Boolean);
|
|
1021
|
+
console.log(` \x1b[90m${ts}\x1b[0m ${src} \x1b[1m${r.app}\x1b[0m wid:${r.wid}`);
|
|
1022
|
+
console.log(` \x1b[36m"${r.title || "(untitled)"}"\x1b[0m`);
|
|
1023
|
+
if (full) {
|
|
1024
|
+
for (const line of lines) {
|
|
1025
|
+
console.log(` \x1b[90m${line}\x1b[0m`);
|
|
1026
|
+
}
|
|
1027
|
+
} else {
|
|
1028
|
+
const maxPreview = 5;
|
|
1029
|
+
const preview = lines.slice(0, maxPreview).map(l => l.length > 100 ? l.slice(0, 97) + "..." : l);
|
|
1030
|
+
for (const line of preview) {
|
|
1031
|
+
console.log(` \x1b[90m${line}\x1b[0m`);
|
|
1032
|
+
}
|
|
1033
|
+
if (lines.length > maxPreview) {
|
|
1034
|
+
console.log(` \x1b[90m… ${lines.length - maxPreview} more lines\x1b[0m`);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
console.log();
|
|
1038
|
+
}
|
|
1039
|
+
} catch {
|
|
1040
|
+
console.log("Daemon not running. Start with: lattices app");
|
|
1041
|
+
}
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
if (sub === "deep" || sub === "now" || sub === "scan") {
|
|
1046
|
+
try {
|
|
1047
|
+
console.log("Triggering deep scan (Vision OCR)...");
|
|
1048
|
+
await daemonCall("ocr.scan", null, 30000);
|
|
1049
|
+
console.log("Done.");
|
|
1050
|
+
} catch (e) {
|
|
1051
|
+
console.log(`Error: ${e.message}`);
|
|
1052
|
+
}
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (sub === "history") {
|
|
1057
|
+
const wid = parseInt(rest[0], 10);
|
|
1058
|
+
if (isNaN(wid)) {
|
|
1059
|
+
console.log("Usage: lattices scan history <wid>");
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
try {
|
|
1063
|
+
const results = await daemonCall("ocr.history", { wid }, 5000);
|
|
1064
|
+
if (!results.length) {
|
|
1065
|
+
console.log(`No history for wid:${wid}.`);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
console.log(`\x1b[1mHistory\x1b[0m wid:${wid} (${results.length} entries)\n`);
|
|
1069
|
+
for (const r of results) {
|
|
1070
|
+
const ts = new Date(r.timestamp * 1000).toLocaleTimeString();
|
|
1071
|
+
const src = r.source === "accessibility" ? "\x1b[33mAX\x1b[0m" : "\x1b[35mOCR\x1b[0m";
|
|
1072
|
+
const lines = (r.fullText || "").split("\n").filter(Boolean);
|
|
1073
|
+
const preview = lines.slice(0, 2).map(l => l.length > 80 ? l.slice(0, 77) + "..." : l);
|
|
1074
|
+
console.log(` \x1b[90m${ts}\x1b[0m ${src} \x1b[1m${r.app}\x1b[0m — "${r.title}"`);
|
|
1075
|
+
for (const line of preview) {
|
|
1076
|
+
console.log(` \x1b[90m${line}\x1b[0m`);
|
|
1077
|
+
}
|
|
1078
|
+
console.log();
|
|
1079
|
+
}
|
|
1080
|
+
} catch (e) {
|
|
1081
|
+
console.log(`Error: ${e.message}`);
|
|
1082
|
+
}
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Unknown subcommand
|
|
1087
|
+
console.log(`lattices scan — Screen text recognition
|
|
1088
|
+
|
|
1089
|
+
Usage:
|
|
1090
|
+
lattices scan Show text from all visible windows
|
|
1091
|
+
lattices scan --full Full text dump
|
|
1092
|
+
lattices scan --json JSON output
|
|
1093
|
+
lattices scan search <q> Full-text search across scanned windows
|
|
1094
|
+
lattices scan recent [n] Show recent scans chronologically (default 20)
|
|
1095
|
+
lattices scan deep Trigger a deep Vision OCR scan
|
|
1096
|
+
lattices scan history <wid> Show scan timeline for a window
|
|
1097
|
+
`);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function printUsage() {
|
|
1101
|
+
console.log(`lattices — Claude Code + dev server in tmux
|
|
1102
|
+
|
|
1103
|
+
Usage:
|
|
1104
|
+
lattices Create session (or reattach) for current project
|
|
1105
|
+
lattices init Generate .lattices.json config for this project
|
|
1106
|
+
lattices ls List active tmux sessions
|
|
1107
|
+
lattices status Show managed vs unmanaged session inventory
|
|
1108
|
+
lattices kill [name] Kill a session (defaults to current project)
|
|
1109
|
+
lattices sync Reconcile session to match declared config
|
|
1110
|
+
lattices restart [pane] Restart a pane's process (by name or index)
|
|
1111
|
+
lattices group [id] List tab groups or launch/attach a group
|
|
1112
|
+
lattices groups List all tab groups with status
|
|
1113
|
+
lattices tab <group> [tab] Switch tab within a group (by label or index)
|
|
1114
|
+
lattices windows [--json] List all desktop windows (daemon required)
|
|
1115
|
+
lattices focus <session> Focus a session's terminal window (daemon required)
|
|
1116
|
+
lattices tile <position> Tile the frontmost window (left, right, top, etc.)
|
|
1117
|
+
lattices distribute Smart-grid all visible windows (daemon required)
|
|
1118
|
+
lattices layer [name|index] List layers or switch by name/index (daemon required)
|
|
1119
|
+
lattices scan Show text from all visible windows
|
|
1120
|
+
lattices scan --full Full text dump
|
|
1121
|
+
lattices scan search <q> Full-text search across scanned windows
|
|
1122
|
+
lattices scan recent [n] Show recent scans chronologically
|
|
1123
|
+
lattices scan deep Trigger a deep Vision OCR scan
|
|
1124
|
+
lattices scan history <wid> Scan timeline for a specific window
|
|
1125
|
+
lattices daemon status Show daemon status
|
|
1126
|
+
lattices diag [limit] Show diagnostic log entries
|
|
1127
|
+
lattices app Launch the menu bar companion app
|
|
1128
|
+
lattices app build Rebuild the menu bar app
|
|
1129
|
+
lattices app restart Rebuild and relaunch the menu bar app
|
|
1130
|
+
lattices app quit Stop the menu bar app
|
|
1131
|
+
lattices help Show this help
|
|
1132
|
+
|
|
1133
|
+
Config (.lattices.json):
|
|
1134
|
+
Place in your project root to customize the layout:
|
|
1135
|
+
|
|
1136
|
+
{
|
|
1137
|
+
"ensure": true,
|
|
1138
|
+
"panes": [
|
|
1139
|
+
{ "name": "claude", "cmd": "claude", "size": 60 },
|
|
1140
|
+
{ "name": "server", "cmd": "pnpm dev" },
|
|
1141
|
+
{ "name": "tests", "cmd": "pnpm test --watch" }
|
|
1142
|
+
]
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
size Width % for the first pane (default: 60)
|
|
1146
|
+
cmd Command to run in the pane
|
|
1147
|
+
name Label (for your reference)
|
|
1148
|
+
ensure Auto-restart exited commands on reattach
|
|
1149
|
+
prefill Type commands into idle panes on reattach (you hit Enter)
|
|
1150
|
+
|
|
1151
|
+
Recovery:
|
|
1152
|
+
lattices sync Recreates missing panes, restores commands, fixes layout.
|
|
1153
|
+
Use when a pane was killed and you want to get back to the
|
|
1154
|
+
declared state without killing the whole session.
|
|
1155
|
+
|
|
1156
|
+
lattices restart Kills the process in a pane and re-runs its declared command.
|
|
1157
|
+
Accepts a pane name or 0-based index (default: 0 / first pane).
|
|
1158
|
+
Examples: lattices restart (restarts "claude")
|
|
1159
|
+
lattices restart server (restarts "server" by name)
|
|
1160
|
+
lattices restart 1 (restarts pane at index 1)
|
|
1161
|
+
|
|
1162
|
+
Layouts:
|
|
1163
|
+
1 pane → single full-width (default when no dev server detected)
|
|
1164
|
+
2 panes → side-by-side split
|
|
1165
|
+
3+ panes → main-vertical (first pane left, rest stacked right)
|
|
1166
|
+
|
|
1167
|
+
┌────────────────────┐ ┌──────────┬─────────┐ ┌──────────┬─────────┐
|
|
1168
|
+
│ claude │ │ claude │ server │ │ claude │ server │
|
|
1169
|
+
│ │ │ (60%) │ (40%) │ │ (60%) ├─────────┤
|
|
1170
|
+
└────────────────────┘ └──────────┴─────────┘ │ │ tests │
|
|
1171
|
+
└──────────┴─────────┘
|
|
1172
|
+
`);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function initConfig() {
|
|
1176
|
+
const dir = process.cwd();
|
|
1177
|
+
const configPath = resolve(dir, ".lattices.json");
|
|
1178
|
+
|
|
1179
|
+
if (existsSync(configPath)) {
|
|
1180
|
+
console.log(".lattices.json already exists.");
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const panes = defaultPanes(dir);
|
|
1185
|
+
const config = {
|
|
1186
|
+
ensure: true,
|
|
1187
|
+
panes,
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
1191
|
+
console.log("Created .lattices.json");
|
|
1192
|
+
console.log(JSON.stringify(config, null, 2));
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function listSessions() {
|
|
1196
|
+
const out = runQuiet(
|
|
1197
|
+
"tmux list-sessions -F '#{session_name} (#{session_windows} windows, created #{session_created_string})'"
|
|
1198
|
+
);
|
|
1199
|
+
if (!out) {
|
|
1200
|
+
console.log("No active tmux sessions.");
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Annotate sessions that belong to tab groups
|
|
1205
|
+
const ws = readWorkspaceConfig();
|
|
1206
|
+
const sessionGroupMap = new Map();
|
|
1207
|
+
if (ws?.groups) {
|
|
1208
|
+
for (const g of ws.groups) {
|
|
1209
|
+
for (const tab of g.tabs || []) {
|
|
1210
|
+
const tabSession = toSessionName(resolve(tab.path));
|
|
1211
|
+
sessionGroupMap.set(tabSession, {
|
|
1212
|
+
group: g.label || g.id,
|
|
1213
|
+
tab: tab.label || basename(tab.path),
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const lines = out.split("\n").map((line) => {
|
|
1220
|
+
const sessionName = line.split(" ")[0];
|
|
1221
|
+
const info = sessionGroupMap.get(sessionName);
|
|
1222
|
+
return info
|
|
1223
|
+
? `${line} \x1b[36m[${info.group}: ${info.tab}]\x1b[0m`
|
|
1224
|
+
: line;
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
console.log("Sessions:\n");
|
|
1228
|
+
console.log(lines.join("\n"));
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function killSession(name) {
|
|
1232
|
+
if (!name) name = toSessionName(process.cwd());
|
|
1233
|
+
if (!sessionExists(name)) {
|
|
1234
|
+
console.log(`No session "${name}".`);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
run(`tmux kill-session -t "${name}"`);
|
|
1238
|
+
console.log(`Killed "${name}".`);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// ── Window tiling ────────────────────────────────────────────────────
|
|
1242
|
+
|
|
1243
|
+
function getScreenBounds() {
|
|
1244
|
+
// Get the visible area (excludes menu bar and dock) in AppleScript coordinates (top-left origin)
|
|
1245
|
+
const script = `
|
|
1246
|
+
tell application "Finder"
|
|
1247
|
+
set db to bounds of window of desktop
|
|
1248
|
+
end tell
|
|
1249
|
+
-- db = {left, top, right, bottom} of usable desktop
|
|
1250
|
+
return (item 1 of db) & "," & (item 2 of db) & "," & (item 3 of db) & "," & (item 4 of db)`;
|
|
1251
|
+
const out = runQuiet(`osascript -e '${esc(script)}'`);
|
|
1252
|
+
if (!out) return { x: 0, y: 25, w: 1920, h: 1055 };
|
|
1253
|
+
const [x, y, right, bottom] = out.split(",").map(s => parseInt(s.trim()));
|
|
1254
|
+
return { x, y, w: right - x, h: bottom - y };
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Presets return AppleScript bounds: [left, top, right, bottom] within the visible area
|
|
1258
|
+
const tilePresets = {
|
|
1259
|
+
"left": (s) => [s.x, s.y, s.x + s.w / 2, s.y + s.h],
|
|
1260
|
+
"left-half": (s) => [s.x, s.y, s.x + s.w / 2, s.y + s.h],
|
|
1261
|
+
"right": (s) => [s.x + s.w / 2, s.y, s.x + s.w, s.y + s.h],
|
|
1262
|
+
"right-half": (s) => [s.x + s.w / 2, s.y, s.x + s.w, s.y + s.h],
|
|
1263
|
+
"top": (s) => [s.x, s.y, s.x + s.w, s.y + s.h / 2],
|
|
1264
|
+
"top-half": (s) => [s.x, s.y, s.x + s.w, s.y + s.h / 2],
|
|
1265
|
+
"bottom": (s) => [s.x, s.y + s.h / 2, s.x + s.w, s.y + s.h],
|
|
1266
|
+
"bottom-half": (s) => [s.x, s.y + s.h / 2, s.x + s.w, s.y + s.h],
|
|
1267
|
+
"top-left": (s) => [s.x, s.y, s.x + s.w / 2, s.y + s.h / 2],
|
|
1268
|
+
"top-right": (s) => [s.x + s.w / 2, s.y, s.x + s.w, s.y + s.h / 2],
|
|
1269
|
+
"bottom-left": (s) => [s.x, s.y + s.h / 2, s.x + s.w / 2, s.y + s.h],
|
|
1270
|
+
"bottom-right": (s) => [s.x + s.w / 2, s.y + s.h / 2, s.x + s.w, s.y + s.h],
|
|
1271
|
+
"maximize": (s) => [s.x, s.y, s.x + s.w, s.y + s.h],
|
|
1272
|
+
"max": (s) => [s.x, s.y, s.x + s.w, s.y + s.h],
|
|
1273
|
+
"center": (s) => {
|
|
1274
|
+
const mw = Math.round(s.w * 0.7);
|
|
1275
|
+
const mh = Math.round(s.h * 0.8);
|
|
1276
|
+
const mx = s.x + Math.round((s.w - mw) / 2);
|
|
1277
|
+
const my = s.y + Math.round((s.h - mh) / 2);
|
|
1278
|
+
return [mx, my, mx + mw, my + mh];
|
|
1279
|
+
},
|
|
1280
|
+
"left-third": (s) => [s.x, s.y, s.x + Math.round(s.w * 0.333), s.y + s.h],
|
|
1281
|
+
"center-third": (s) => [s.x + Math.round(s.w * 0.333), s.y, s.x + Math.round(s.w * 0.667), s.y + s.h],
|
|
1282
|
+
"right-third": (s) => [s.x + Math.round(s.w * 0.667), s.y, s.x + s.w, s.y + s.h],
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1285
|
+
function tileWindow(position) {
|
|
1286
|
+
const preset = tilePresets[position];
|
|
1287
|
+
if (!preset) {
|
|
1288
|
+
console.log(`Unknown position: ${position}`);
|
|
1289
|
+
console.log(`Available: ${Object.keys(tilePresets).filter(k => !k.includes("-half") && k !== "max").join(", ")}`);
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
const screen = getScreenBounds();
|
|
1293
|
+
const [x1, y1, x2, y2] = preset(screen).map(Math.round);
|
|
1294
|
+
const script = `
|
|
1295
|
+
tell application "System Events"
|
|
1296
|
+
set frontApp to name of first application process whose frontmost is true
|
|
1297
|
+
end tell
|
|
1298
|
+
tell application frontApp
|
|
1299
|
+
set bounds of front window to {${x1}, ${y1}, ${x2}, ${y2}}
|
|
1300
|
+
end tell`;
|
|
1301
|
+
runQuiet(`osascript -e '${esc(script)}'`);
|
|
1302
|
+
console.log(`Tiled → ${position}`);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function createOrAttach() {
|
|
1306
|
+
const dir = process.cwd();
|
|
1307
|
+
const name = toSessionName(dir);
|
|
1308
|
+
|
|
1309
|
+
if (sessionExists(name)) {
|
|
1310
|
+
console.log(`Reattaching to "${name}"...`);
|
|
1311
|
+
const config = readConfig(dir);
|
|
1312
|
+
if (config?.ensure) {
|
|
1313
|
+
restoreCommands(name, dir, "ensure");
|
|
1314
|
+
} else if (config?.prefill) {
|
|
1315
|
+
restoreCommands(name, dir, "prefill");
|
|
1316
|
+
}
|
|
1317
|
+
attach(name);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
console.log(`Creating "${name}"...`);
|
|
1322
|
+
createSession(dir);
|
|
1323
|
+
attach(name);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function attach(name) {
|
|
1327
|
+
if (isInsideTmux()) {
|
|
1328
|
+
execSync(`tmux switch-client -t "${name}"`, { stdio: "inherit" });
|
|
1329
|
+
} else {
|
|
1330
|
+
execSync(`tmux attach -t "${name}"`, { stdio: "inherit" });
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// ── Status / Inventory ───────────────────────────────────────────────
|
|
1335
|
+
|
|
1336
|
+
function statusInventory() {
|
|
1337
|
+
// Query all tmux sessions
|
|
1338
|
+
const sessionsRaw = runQuiet(
|
|
1339
|
+
'tmux list-sessions -F "#{session_name}\t#{session_windows}\t#{session_attached}"'
|
|
1340
|
+
);
|
|
1341
|
+
if (!sessionsRaw) {
|
|
1342
|
+
console.log("No active tmux sessions.");
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Query all panes
|
|
1347
|
+
const panesRaw = runQuiet(
|
|
1348
|
+
'tmux list-panes -a -F "#{session_name}\t#{pane_title}\t#{pane_current_command}"'
|
|
1349
|
+
);
|
|
1350
|
+
|
|
1351
|
+
// Parse panes grouped by session
|
|
1352
|
+
const panesBySession = new Map();
|
|
1353
|
+
if (panesRaw) {
|
|
1354
|
+
for (const line of panesRaw.split("\n").filter(Boolean)) {
|
|
1355
|
+
const [sess, title, cmd] = line.split("\t");
|
|
1356
|
+
if (!panesBySession.has(sess)) panesBySession.set(sess, []);
|
|
1357
|
+
panesBySession.get(sess).push({ title, cmd });
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// Build managed session name set
|
|
1362
|
+
const managed = new Map(); // name -> label
|
|
1363
|
+
|
|
1364
|
+
// From workspace groups
|
|
1365
|
+
const ws = readWorkspaceConfig();
|
|
1366
|
+
if (ws?.groups) {
|
|
1367
|
+
for (const g of ws.groups) {
|
|
1368
|
+
for (const tab of g.tabs || []) {
|
|
1369
|
+
const name = toSessionName(resolve(tab.path));
|
|
1370
|
+
const label = `${g.label || g.id}: ${tab.label || basename(tab.path)}`;
|
|
1371
|
+
managed.set(name, label);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// From scanning .lattices.json files
|
|
1377
|
+
const scanRoot =
|
|
1378
|
+
process.env.LATTICE_SCAN_ROOT ||
|
|
1379
|
+
resolve(homedir(), "dev");
|
|
1380
|
+
const findResult = runQuiet(
|
|
1381
|
+
`find "${scanRoot}" -name .lattices.json -maxdepth 3 -not -path "*/.git/*" -not -path "*/node_modules/*" 2>/dev/null`
|
|
1382
|
+
);
|
|
1383
|
+
if (findResult) {
|
|
1384
|
+
for (const configPath of findResult.split("\n").filter(Boolean)) {
|
|
1385
|
+
const dir = resolve(configPath, "..");
|
|
1386
|
+
const name = toSessionName(dir);
|
|
1387
|
+
if (!managed.has(name)) {
|
|
1388
|
+
managed.set(name, basename(dir));
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// Parse sessions and classify
|
|
1394
|
+
const sessions = sessionsRaw.split("\n").filter(Boolean).map((line) => {
|
|
1395
|
+
const [name, windows, attached] = line.split("\t");
|
|
1396
|
+
return { name, windows: parseInt(windows) || 1, attached: attached !== "0" };
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
const managedSessions = sessions.filter((s) => managed.has(s.name));
|
|
1400
|
+
const orphanSessions = sessions.filter((s) => !managed.has(s.name));
|
|
1401
|
+
|
|
1402
|
+
// Print managed
|
|
1403
|
+
if (managedSessions.length > 0) {
|
|
1404
|
+
console.log(`\x1b[32m●\x1b[0m Managed Sessions (${managedSessions.length})\n`);
|
|
1405
|
+
for (const s of managedSessions) {
|
|
1406
|
+
const label = managed.get(s.name);
|
|
1407
|
+
const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
|
|
1408
|
+
console.log(` \x1b[1m${s.name}\x1b[0m (${s.windows} window${s.windows === 1 ? "" : "s"})${attachTag} \x1b[36m[${label}]\x1b[0m`);
|
|
1409
|
+
const panes = panesBySession.get(s.name) || [];
|
|
1410
|
+
for (const p of panes) {
|
|
1411
|
+
const name = p.title || "pane";
|
|
1412
|
+
console.log(` ${name}: ${p.cmd}`);
|
|
1413
|
+
}
|
|
1414
|
+
console.log();
|
|
1415
|
+
}
|
|
1416
|
+
} else {
|
|
1417
|
+
console.log("\x1b[90m○\x1b[0m No managed sessions running.\n");
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// Print orphans
|
|
1421
|
+
if (orphanSessions.length > 0) {
|
|
1422
|
+
console.log(`\x1b[33m○\x1b[0m Unmanaged Sessions (${orphanSessions.length})\n`);
|
|
1423
|
+
for (const s of orphanSessions) {
|
|
1424
|
+
const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
|
|
1425
|
+
console.log(` \x1b[1m${s.name}\x1b[0m (${s.windows} window${s.windows === 1 ? "" : "s"})${attachTag}`);
|
|
1426
|
+
const panes = panesBySession.get(s.name) || [];
|
|
1427
|
+
for (const p of panes) {
|
|
1428
|
+
const name = p.title || "pane";
|
|
1429
|
+
console.log(` ${name}: ${p.cmd}`);
|
|
1430
|
+
}
|
|
1431
|
+
console.log();
|
|
1432
|
+
}
|
|
1433
|
+
} else {
|
|
1434
|
+
console.log("\x1b[90m○\x1b[0m No unmanaged sessions.\n");
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// ── Main ─────────────────────────────────────────────────────────────
|
|
1439
|
+
|
|
1440
|
+
if (!hasTmux()) {
|
|
1441
|
+
console.error("tmux is not installed. Install with: brew install tmux");
|
|
1442
|
+
process.exit(1);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
switch (command) {
|
|
1446
|
+
case "init":
|
|
1447
|
+
initConfig();
|
|
1448
|
+
break;
|
|
1449
|
+
case "ls":
|
|
1450
|
+
case "list":
|
|
1451
|
+
// Try daemon first, fall back to direct tmux
|
|
1452
|
+
if (!(await daemonLsCommand())) {
|
|
1453
|
+
listSessions();
|
|
1454
|
+
}
|
|
1455
|
+
break;
|
|
1456
|
+
case "kill":
|
|
1457
|
+
case "rm":
|
|
1458
|
+
killSession(args[1]);
|
|
1459
|
+
break;
|
|
1460
|
+
case "sync":
|
|
1461
|
+
case "reconcile":
|
|
1462
|
+
syncSession();
|
|
1463
|
+
break;
|
|
1464
|
+
case "restart":
|
|
1465
|
+
case "respawn":
|
|
1466
|
+
restartPane(args[1]);
|
|
1467
|
+
break;
|
|
1468
|
+
case "group":
|
|
1469
|
+
groupCommand(args[1]);
|
|
1470
|
+
break;
|
|
1471
|
+
case "groups":
|
|
1472
|
+
listGroups();
|
|
1473
|
+
break;
|
|
1474
|
+
case "tab":
|
|
1475
|
+
tabCommand(args[1], args[2]);
|
|
1476
|
+
break;
|
|
1477
|
+
case "status":
|
|
1478
|
+
case "inventory":
|
|
1479
|
+
// Try daemon first, fall back to direct tmux
|
|
1480
|
+
if (!(await daemonStatusInventory())) {
|
|
1481
|
+
statusInventory();
|
|
1482
|
+
}
|
|
1483
|
+
break;
|
|
1484
|
+
case "distribute":
|
|
1485
|
+
await distributeCommand();
|
|
1486
|
+
break;
|
|
1487
|
+
case "tile":
|
|
1488
|
+
case "t":
|
|
1489
|
+
if (args[1]) {
|
|
1490
|
+
tileWindow(args[1]);
|
|
1491
|
+
} else {
|
|
1492
|
+
console.log("Usage: lattices tile <position>\n");
|
|
1493
|
+
console.log("Positions: left, right, top, bottom, top-left, top-right,");
|
|
1494
|
+
console.log(" bottom-left, bottom-right, maximize, center,");
|
|
1495
|
+
console.log(" left-third, center-third, right-third");
|
|
1496
|
+
}
|
|
1497
|
+
break;
|
|
1498
|
+
case "windows":
|
|
1499
|
+
await windowsCommand(args[1] === "--json");
|
|
1500
|
+
break;
|
|
1501
|
+
case "window":
|
|
1502
|
+
if (args[1] === "assign") {
|
|
1503
|
+
await windowAssignCommand(args[2], args[3]);
|
|
1504
|
+
} else if (args[1] === "map") {
|
|
1505
|
+
await windowLayerMapCommand(args[2] === "--json");
|
|
1506
|
+
} else {
|
|
1507
|
+
console.log("Usage:");
|
|
1508
|
+
console.log(" lattices window assign <wid> <layer-id> Tag a window to a layer");
|
|
1509
|
+
console.log(" lattices window map [--json] Show all layer tags");
|
|
1510
|
+
}
|
|
1511
|
+
break;
|
|
1512
|
+
case "focus":
|
|
1513
|
+
await focusCommand(args[1]);
|
|
1514
|
+
break;
|
|
1515
|
+
case "layer":
|
|
1516
|
+
case "layers":
|
|
1517
|
+
await layerCommand(args[1]);
|
|
1518
|
+
break;
|
|
1519
|
+
case "diag":
|
|
1520
|
+
case "diagnostics":
|
|
1521
|
+
await diagCommand(args[1]);
|
|
1522
|
+
break;
|
|
1523
|
+
case "scan":
|
|
1524
|
+
case "ocr":
|
|
1525
|
+
await scanCommand(args[1], ...args.slice(2));
|
|
1526
|
+
break;
|
|
1527
|
+
case "daemon":
|
|
1528
|
+
if (args[1] === "status") {
|
|
1529
|
+
await daemonStatusCommand();
|
|
1530
|
+
} else {
|
|
1531
|
+
console.log("Usage: lattices daemon status");
|
|
1532
|
+
}
|
|
1533
|
+
break;
|
|
1534
|
+
case "app": {
|
|
1535
|
+
// Forward to lattices-app script
|
|
1536
|
+
const { execFileSync } = await import("node:child_process");
|
|
1537
|
+
const __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
1538
|
+
const appScript = resolve(__dirname2, "lattices-app.js");
|
|
1539
|
+
try {
|
|
1540
|
+
execFileSync("node", [appScript, ...args.slice(1)], { stdio: "inherit" });
|
|
1541
|
+
} catch { /* exit code forwarded */ }
|
|
1542
|
+
break;
|
|
1543
|
+
}
|
|
1544
|
+
case "-h":
|
|
1545
|
+
case "--help":
|
|
1546
|
+
case "help":
|
|
1547
|
+
printUsage();
|
|
1548
|
+
break;
|
|
1549
|
+
default:
|
|
1550
|
+
createOrAttach();
|
|
1551
|
+
}
|