paneful 0.7.3 → 0.8.1

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 CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  A terminal multiplexer that runs in your browser. Split panes, organize by project, drag and drop from Finder, sync with your editor — all from a single `npm install`.
4
4
 
5
+ **Website:** [kplates.github.io/paneful](https://kplates.github.io/paneful/)
6
+
5
7
  ![Paneful](screenshot.png)
6
8
 
7
9
  ## Install
@@ -18,6 +20,8 @@ paneful --port 8080 # Use a specific port
18
20
  paneful --spawn # Add current directory as a project
19
21
  paneful --list # List all projects
20
22
  paneful --kill my-project # Kill a project by name
23
+ paneful update # Update to the latest version
24
+ paneful --install-app # Install as a native macOS app
21
25
  ```
22
26
 
23
27
  ## Features
@@ -39,7 +43,8 @@ Save a workspace layout as a favourite — name, layout preset, and per-pane com
39
43
  Automatically switches the active project based on which editor window is in focus. Works with VS Code, Cursor, Zed, and Windsurf on macOS. Toggle via the monitor icon in the sidebar header.
40
44
 
41
45
  Requires:
42
- 1. Terminal app added to **System Settings > Privacy & Security > Accessibility**
46
+
47
+ 1. **Paneful** (native app) or **Terminal** (CLI) added to **System Settings > Privacy & Security > Accessibility**
43
48
  2. Editor window title includes the folder name (default in VS Code/Cursor)
44
49
 
45
50
  ### Resizable Sidebar
@@ -50,24 +55,42 @@ Drag the right edge of the sidebar to resize it. Width persists across sessions.
50
55
 
51
56
  Press `Cmd+R` or click the dashboard icon in the toolbar to automatically pick the best layout for your current pane count.
52
57
 
58
+ ### Native macOS App
59
+
60
+ Install Paneful as a standalone macOS app with its own Dock icon and window:
61
+
62
+ ```bash
63
+ paneful --install-app
64
+ ```
65
+
66
+ A folder picker dialog lets you choose the install location (defaults to `/Applications`). The app launches Paneful in a native WebKit window — no browser tab needed. Updating via `paneful update` automatically rebuilds the `.app` in place.
67
+
68
+ ### Updating
69
+
70
+ ```bash
71
+ paneful update
72
+ ```
73
+
74
+ Checks npm for the latest version, installs it globally, and rebuilds the native `.app` if one is installed. The Dock icon stays valid automatically.
75
+
53
76
  ### Update Notifications
54
77
 
55
78
  Paneful checks for newer versions on npm and shows a notification in the sidebar when an update is available.
56
79
 
57
80
  ## Keyboard Shortcuts
58
81
 
59
- | Shortcut | Action |
60
- | ----------------- | ------------------------------- |
61
- | `Cmd+N` | New pane (vertical split) |
62
- | `Cmd+Shift+N` | New pane (horizontal split) |
63
- | `Cmd+W` | Close focused pane |
64
- | `Cmd+1-9` | Focus pane by index |
65
- | `Cmd+Arrow` | Line start / end in terminal |
66
- | `Ctrl+Shift+Arrow`| Move focus to adjacent pane |
67
- | `Shift+Arrow` | Swap focused pane with adjacent |
68
- | `Cmd+D` | Toggle sidebar |
69
- | `Cmd+T` | Cycle through layout presets |
70
- | `Cmd+R` | Auto reorganize panes |
82
+ | Shortcut | Action |
83
+ | ------------------ | ------------------------------- |
84
+ | `Cmd+N` | New pane (vertical split) |
85
+ | `Cmd+Shift+N` | New pane (horizontal split) |
86
+ | `Cmd+W` | Close focused pane |
87
+ | `Cmd+1-9` | Focus pane by index |
88
+ | `Cmd+Arrow` | Line start / end in terminal |
89
+ | `Ctrl+Shift+Arrow` | Move focus to adjacent pane |
90
+ | `Shift+Arrow` | Swap focused pane with adjacent |
91
+ | `Cmd+D` | Toggle sidebar |
92
+ | `Cmd+T` | Cycle through layout presets |
93
+ | `Cmd+R` | Auto reorganize panes |
71
94
 
72
95
  ## Layout Presets
73
96
 
@@ -0,0 +1,318 @@
1
+ {
2
+ "images": [
3
+ {
4
+ "idiom": "universal",
5
+ "platform": "ios",
6
+ "scale": "2x",
7
+ "size": "20x20",
8
+ "filename": "icon-ios-20x20@2x.png"
9
+ },
10
+ {
11
+ "idiom": "universal",
12
+ "platform": "ios",
13
+ "scale": "3x",
14
+ "size": "20x20",
15
+ "filename": "icon-ios-20x20@3x.png"
16
+ },
17
+ {
18
+ "idiom": "universal",
19
+ "platform": "ios",
20
+ "scale": "2x",
21
+ "size": "29x29",
22
+ "filename": "icon-ios-29x29@2x.png"
23
+ },
24
+ {
25
+ "idiom": "universal",
26
+ "platform": "ios",
27
+ "scale": "3x",
28
+ "size": "29x29",
29
+ "filename": "icon-ios-29x29@3x.png"
30
+ },
31
+ {
32
+ "idiom": "universal",
33
+ "platform": "ios",
34
+ "scale": "2x",
35
+ "size": "38x38",
36
+ "filename": "icon-ios-38x38@2x.png"
37
+ },
38
+ {
39
+ "idiom": "universal",
40
+ "platform": "ios",
41
+ "scale": "3x",
42
+ "size": "38x38",
43
+ "filename": "icon-ios-38x38@3x.png"
44
+ },
45
+ {
46
+ "idiom": "universal",
47
+ "platform": "ios",
48
+ "scale": "2x",
49
+ "size": "40x40",
50
+ "filename": "icon-ios-40x40@2x.png"
51
+ },
52
+ {
53
+ "idiom": "universal",
54
+ "platform": "ios",
55
+ "scale": "3x",
56
+ "size": "40x40",
57
+ "filename": "icon-ios-40x40@3x.png"
58
+ },
59
+ {
60
+ "idiom": "universal",
61
+ "platform": "ios",
62
+ "scale": "2x",
63
+ "size": "60x60",
64
+ "filename": "icon-ios-60x60@2x.png"
65
+ },
66
+ {
67
+ "idiom": "universal",
68
+ "platform": "ios",
69
+ "scale": "3x",
70
+ "size": "60x60",
71
+ "filename": "icon-ios-60x60@3x.png"
72
+ },
73
+ {
74
+ "idiom": "universal",
75
+ "platform": "ios",
76
+ "scale": "2x",
77
+ "size": "64x64",
78
+ "filename": "icon-ios-64x64@2x.png"
79
+ },
80
+ {
81
+ "idiom": "universal",
82
+ "platform": "ios",
83
+ "scale": "3x",
84
+ "size": "64x64",
85
+ "filename": "icon-ios-64x64@3x.png"
86
+ },
87
+ {
88
+ "idiom": "universal",
89
+ "platform": "ios",
90
+ "scale": "2x",
91
+ "size": "68x68",
92
+ "filename": "icon-ios-68x68@2x.png"
93
+ },
94
+ {
95
+ "idiom": "universal",
96
+ "platform": "ios",
97
+ "scale": "2x",
98
+ "size": "76x76",
99
+ "filename": "icon-ios-76x76@2x.png"
100
+ },
101
+ {
102
+ "idiom": "universal",
103
+ "platform": "ios",
104
+ "scale": "2x",
105
+ "size": "83.5x83.5",
106
+ "filename": "icon-ios-83.5x83.5@2x.png"
107
+ },
108
+ {
109
+ "idiom": "universal",
110
+ "platform": "ios",
111
+ "size": "1024x1024",
112
+ "filename": "icon-ios-1024x1024.png"
113
+ },
114
+ {
115
+ "idiom": "mac",
116
+ "scale": "1x",
117
+ "size": "16x16",
118
+ "filename": "icon-mac-16x16.png"
119
+ },
120
+ {
121
+ "idiom": "mac",
122
+ "scale": "2x",
123
+ "size": "16x16",
124
+ "filename": "icon-mac-16x16@2x.png"
125
+ },
126
+ {
127
+ "idiom": "mac",
128
+ "scale": "1x",
129
+ "size": "32x32",
130
+ "filename": "icon-mac-32x32.png"
131
+ },
132
+ {
133
+ "idiom": "mac",
134
+ "scale": "2x",
135
+ "size": "32x32",
136
+ "filename": "icon-mac-32x32@2x.png"
137
+ },
138
+ {
139
+ "idiom": "mac",
140
+ "scale": "1x",
141
+ "size": "128x128",
142
+ "filename": "icon-mac-128x128.png"
143
+ },
144
+ {
145
+ "idiom": "mac",
146
+ "scale": "2x",
147
+ "size": "128x128",
148
+ "filename": "icon-mac-128x128@2x.png"
149
+ },
150
+ {
151
+ "idiom": "mac",
152
+ "scale": "1x",
153
+ "size": "256x256",
154
+ "filename": "icon-mac-256x256.png"
155
+ },
156
+ {
157
+ "idiom": "mac",
158
+ "scale": "2x",
159
+ "size": "256x256",
160
+ "filename": "icon-mac-256x256@2x.png"
161
+ },
162
+ {
163
+ "idiom": "mac",
164
+ "scale": "1x",
165
+ "size": "512x512",
166
+ "filename": "icon-mac-512x512.png"
167
+ },
168
+ {
169
+ "idiom": "mac",
170
+ "scale": "2x",
171
+ "size": "512x512",
172
+ "filename": "icon-mac-512x512@2x.png"
173
+ },
174
+ {
175
+ "idiom": "universal",
176
+ "platform": "watchos",
177
+ "scale": "2x",
178
+ "size": "22x22",
179
+ "filename": "icon-watchos-22x22@2x.png"
180
+ },
181
+ {
182
+ "idiom": "universal",
183
+ "platform": "watchos",
184
+ "scale": "2x",
185
+ "size": "24x24",
186
+ "filename": "icon-watchos-24x24@2x.png"
187
+ },
188
+ {
189
+ "idiom": "universal",
190
+ "platform": "watchos",
191
+ "scale": "2x",
192
+ "size": "27.5x27.5",
193
+ "filename": "icon-watchos-27.5x27.5@2x.png"
194
+ },
195
+ {
196
+ "idiom": "universal",
197
+ "platform": "watchos",
198
+ "scale": "2x",
199
+ "size": "29x29",
200
+ "filename": "icon-watchos-29x29@2x.png"
201
+ },
202
+ {
203
+ "idiom": "universal",
204
+ "platform": "watchos",
205
+ "scale": "2x",
206
+ "size": "30x30",
207
+ "filename": "icon-watchos-30x30@2x.png"
208
+ },
209
+ {
210
+ "idiom": "universal",
211
+ "platform": "watchos",
212
+ "scale": "2x",
213
+ "size": "32x32",
214
+ "filename": "icon-watchos-32x32@2x.png"
215
+ },
216
+ {
217
+ "idiom": "universal",
218
+ "platform": "watchos",
219
+ "scale": "2x",
220
+ "size": "33x33",
221
+ "filename": "icon-watchos-33x33@2x.png"
222
+ },
223
+ {
224
+ "idiom": "universal",
225
+ "platform": "watchos",
226
+ "scale": "2x",
227
+ "size": "40x40",
228
+ "filename": "icon-watchos-40x40@2x.png"
229
+ },
230
+ {
231
+ "idiom": "universal",
232
+ "platform": "watchos",
233
+ "scale": "2x",
234
+ "size": "43.5x43.5",
235
+ "filename": "icon-watchos-43.5x43.5@2x.png"
236
+ },
237
+ {
238
+ "idiom": "universal",
239
+ "platform": "watchos",
240
+ "scale": "2x",
241
+ "size": "44x44",
242
+ "filename": "icon-watchos-44x44@2x.png"
243
+ },
244
+ {
245
+ "idiom": "universal",
246
+ "platform": "watchos",
247
+ "scale": "2x",
248
+ "size": "46x46",
249
+ "filename": "icon-watchos-46x46@2x.png"
250
+ },
251
+ {
252
+ "idiom": "universal",
253
+ "platform": "watchos",
254
+ "scale": "2x",
255
+ "size": "50x50",
256
+ "filename": "icon-watchos-50x50@2x.png"
257
+ },
258
+ {
259
+ "idiom": "universal",
260
+ "platform": "watchos",
261
+ "scale": "2x",
262
+ "size": "51x51",
263
+ "filename": "icon-watchos-51x51@2x.png"
264
+ },
265
+ {
266
+ "idiom": "universal",
267
+ "platform": "watchos",
268
+ "scale": "2x",
269
+ "size": "54x54",
270
+ "filename": "icon-watchos-54x54@2x.png"
271
+ },
272
+ {
273
+ "idiom": "universal",
274
+ "platform": "watchos",
275
+ "scale": "2x",
276
+ "size": "86x86",
277
+ "filename": "icon-watchos-86x86@2x.png"
278
+ },
279
+ {
280
+ "idiom": "universal",
281
+ "platform": "watchos",
282
+ "scale": "2x",
283
+ "size": "98x98",
284
+ "filename": "icon-watchos-98x98@2x.png"
285
+ },
286
+ {
287
+ "idiom": "universal",
288
+ "platform": "watchos",
289
+ "scale": "2x",
290
+ "size": "108x108",
291
+ "filename": "icon-watchos-108x108@2x.png"
292
+ },
293
+ {
294
+ "idiom": "universal",
295
+ "platform": "watchos",
296
+ "scale": "2x",
297
+ "size": "117x117",
298
+ "filename": "icon-watchos-117x117@2x.png"
299
+ },
300
+ {
301
+ "idiom": "universal",
302
+ "platform": "watchos",
303
+ "scale": "2x",
304
+ "size": "129x129",
305
+ "filename": "icon-watchos-129x129@2x.png"
306
+ },
307
+ {
308
+ "idiom": "universal",
309
+ "platform": "watchos",
310
+ "size": "1024x1024",
311
+ "filename": "icon-watchos-1024x1024.png"
312
+ }
313
+ ],
314
+ "info": {
315
+ "author": "makeappicon",
316
+ "version": 1
317
+ }
318
+ }
Binary file
Binary file
@@ -58,20 +58,22 @@ export function openBrowser(port) {
58
58
  function tryChromeAppMode(url) {
59
59
  const appArg = `--app=${url}`;
60
60
  if (process.platform === 'darwin') {
61
+ // Use "open -na" to launch via macOS app framework — directly executing
62
+ // the binary is unreliable from .app bundles and can trigger the
63
+ // "choose application" dialog.
61
64
  const browsers = [
62
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
63
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
64
- '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
65
- '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
66
- '/Applications/Arc.app/Contents/MacOS/Arc',
65
+ { app: 'Google Chrome', path: '/Applications/Google Chrome.app' },
66
+ { app: 'Chromium', path: '/Applications/Chromium.app' },
67
+ { app: 'Microsoft Edge', path: '/Applications/Microsoft Edge.app' },
68
+ { app: 'Brave Browser', path: '/Applications/Brave Browser.app' },
67
69
  ];
68
- for (const browser of browsers) {
69
- if (fs.existsSync(browser)) {
70
- execFile(browser, [appArg], (err) => {
70
+ for (const { app, path: appPath } of browsers) {
71
+ if (fs.existsSync(appPath)) {
72
+ execFile('open', ['-na', app, '--args', appArg], (err) => {
71
73
  if (err)
72
- console.warn(`Failed to launch ${browser}:`, err.message);
74
+ console.warn(`Failed to launch ${app}:`, err.message);
73
75
  });
74
- console.log(`Opened in app mode via ${browser}`);
76
+ console.log(`Opened in app mode via ${app}`);
75
77
  return true;
76
78
  }
77
79
  }
@@ -37,6 +37,17 @@ async function getLatestVersion() {
37
37
  return null;
38
38
  }
39
39
  }
40
+ function isNewerVersion(latest, current) {
41
+ const l = latest.split('.').map(Number);
42
+ const c = current.split('.').map(Number);
43
+ for (let i = 0; i < 3; i++) {
44
+ if ((l[i] || 0) > (c[i] || 0))
45
+ return true;
46
+ if ((l[i] || 0) < (c[i] || 0))
47
+ return false;
48
+ }
49
+ return false;
50
+ }
40
51
  // ── Paths ──
41
52
  function dataDir() {
42
53
  const dir = path.join(os.homedir(), '.paneful');
@@ -171,7 +182,7 @@ async function handleKill(name) {
171
182
  async function startServer(devMode, port) {
172
183
  // Lazy-load heavy dependencies (express, node-pty, ws, etc.)
173
184
  // so CLI commands that don't need the server start instantly
174
- const [{ default: http }, { default: express }, { execFile }, { v4: uuidv4 }, { PtyManager }, { ProjectStore }, { WsHandler }, { startIpcListener },] = await Promise.all([
185
+ const [{ default: http }, { default: express }, { execFile }, { v4: uuidv4 }, { PtyManager }, { ProjectStore }, { WsHandler }, { startIpcListener }, { SettingsStore },] = await Promise.all([
175
186
  import('node:http'),
176
187
  import('express'),
177
188
  import('node:child_process'),
@@ -180,11 +191,13 @@ async function startServer(devMode, port) {
180
191
  import('./project-store.js'),
181
192
  import('./ws-handler.js'),
182
193
  import('./ipc.js'),
194
+ import('./settings-store.js'),
183
195
  ]);
184
196
  const app = express();
185
197
  app.use(express.json());
186
198
  const ptyManager = new PtyManager();
187
199
  const projectStore = new ProjectStore(dataDir());
200
+ const settingsStore = new SettingsStore(dataDir());
188
201
  // API routes
189
202
  app.get('/api/projects', (_req, res) => {
190
203
  res.json(projectStore.list());
@@ -205,6 +218,12 @@ async function startServer(devMode, port) {
205
218
  const latest = await getLatestVersion();
206
219
  res.json({ current: CURRENT_VERSION, latest });
207
220
  });
221
+ app.get('/api/settings', (_req, res) => {
222
+ res.json(settingsStore.get());
223
+ });
224
+ app.put('/api/settings', (req, res) => {
225
+ res.json(settingsStore.update(req.body));
226
+ });
208
227
  app.post('/api/projects/:id/kill', (req, res) => {
209
228
  const killed = ptyManager.killProject(req.params.id);
210
229
  res.json({ killed: killed.length });
@@ -272,6 +291,9 @@ async function startServer(devMode, port) {
272
291
  else if (parts.length === 2) {
273
292
  projectName = parts[0];
274
293
  }
294
+ else {
295
+ projectName = title;
296
+ }
275
297
  }
276
298
  const prev = editorCache.projectName;
277
299
  editorCache = { projectName };
@@ -367,7 +389,7 @@ async function startServer(devMode, port) {
367
389
  }
368
390
  const server = http.createServer(app);
369
391
  // WebSocket handler
370
- const wsHandler = new WsHandler(server, ptyManager, projectStore);
392
+ const wsHandler = new WsHandler(server, ptyManager, projectStore, { onIdle: () => shutdown() });
371
393
  // IPC listener
372
394
  const ipcServer = startIpcListener(socketPath(), ptyManager, projectStore, wsHandler);
373
395
  server.on('error', (err) => {
@@ -383,18 +405,25 @@ async function startServer(devMode, port) {
383
405
  const actualPort = typeof addr === 'object' && addr ? addr.port : port;
384
406
  writeLockfile(process.pid, actualPort);
385
407
  console.log(`Paneful running on http://localhost:${actualPort}`);
386
- if (!devMode) {
408
+ if (!devMode && !process.env.PANEFUL_APP) {
387
409
  openBrowser(actualPort);
388
410
  }
389
411
  });
390
412
  // Graceful shutdown
413
+ let shuttingDown = false;
391
414
  const shutdown = () => {
415
+ if (shuttingDown)
416
+ return;
417
+ shuttingDown = true;
392
418
  console.log('Shutting down...');
393
419
  ptyManager.killAll();
394
420
  removeLockfile();
395
421
  ipcServer.close();
396
- server.close();
397
- process.exit(0);
422
+ server.close(() => {
423
+ process.exit(0);
424
+ });
425
+ // Force exit if server.close() hangs
426
+ setTimeout(() => process.exit(0), 2000);
398
427
  };
399
428
  process.on('SIGINT', shutdown);
400
429
  process.on('SIGTERM', shutdown);
@@ -406,13 +435,59 @@ program
406
435
  .option('--spawn', 'Spawn a new project in the current directory')
407
436
  .option('--list', 'List all projects')
408
437
  .option('--kill <name>', 'Kill a project by name')
409
- .option('--install-app', 'Create Paneful.app in /Applications (macOS only)')
438
+ .option('--install-app', 'Create Paneful.app (macOS only)')
439
+ .option('--app-path <path>', 'Custom path for Paneful.app (default: /Applications/Paneful.app)')
410
440
  .option('--dev', 'Run in development mode (proxy to Vite dev server)')
411
- .option('--port <number>', 'Port to listen on (default: random available)', parseInt)
412
- .action(async (opts) => {
441
+ .option('--port <number>', 'Port to listen on (default: random)', parseInt);
442
+ program
443
+ .command('update')
444
+ .description('Update paneful to the latest version')
445
+ .action(async () => {
446
+ const { execSync } = await import('node:child_process');
447
+ const latest = await getLatestVersion();
448
+ if (!latest) {
449
+ console.log('Could not check for updates. Try: npm install -g paneful@latest');
450
+ process.exit(1);
451
+ }
452
+ if (!isNewerVersion(latest, CURRENT_VERSION)) {
453
+ console.log(`Already on latest version (v${CURRENT_VERSION})`);
454
+ return;
455
+ }
456
+ console.log(`Updating paneful v${CURRENT_VERSION} → v${latest}...`);
457
+ execSync('npm install -g paneful@latest', { stdio: 'inherit' });
458
+ // Find existing Paneful.app to rebuild it in place
459
+ let appPath = null;
460
+ if (process.platform === 'darwin') {
461
+ try {
462
+ const found = execSync("mdfind \"kMDItemCFBundleIdentifier == 'com.paneful.app'\"", { encoding: 'utf-8' }).trim();
463
+ if (found)
464
+ appPath = found.split('\n')[0];
465
+ }
466
+ catch { /* spotlight unavailable */ }
467
+ // Fallback: check common locations
468
+ if (!appPath) {
469
+ for (const p of [
470
+ path.join(os.homedir(), 'Applications', 'Paneful.app'),
471
+ '/Applications/Paneful.app',
472
+ ]) {
473
+ if (fs.existsSync(p)) {
474
+ appPath = p;
475
+ break;
476
+ }
477
+ }
478
+ }
479
+ }
480
+ if (appPath) {
481
+ console.log(`\nRebuilding ${appPath}...`);
482
+ const { installApp } = await import('./install-app.js');
483
+ await installApp(appPath);
484
+ }
485
+ console.log('\nUpdate complete!');
486
+ });
487
+ program.action(async (opts) => {
413
488
  if (opts.installApp) {
414
489
  const { installApp } = await import('./install-app.js');
415
- await installApp();
490
+ await installApp(opts.appPath);
416
491
  return;
417
492
  }
418
493
  if (opts.list) {
@@ -439,6 +514,6 @@ program
439
514
  if (lock) {
440
515
  removeLockfile();
441
516
  }
442
- await startServer(opts.dev || false, opts.port || 56170);
517
+ await startServer(opts.dev || false, opts.port || 0);
443
518
  });
444
519
  program.parse();