paneful 0.9.7 → 0.9.9

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
@@ -1,10 +1,10 @@
1
1
  # Paneful
2
2
 
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`.
3
+ A fast, GPU-accelerated terminal multiplexer that runs in your browser or as a native macOS app. Split panes, organize projects, sync with your editor, and detect AI agents and dev servers automatically. One `npm install`, no config.
4
4
 
5
5
  **Website:** [paneful.dev](https://paneful.dev)
6
6
 
7
- ![Paneful](screenshot.png)
7
+ ![Paneful](screenshot.gif)
8
8
 
9
9
  ## Install
10
10
 
@@ -51,6 +51,10 @@ Requires:
51
51
 
52
52
  Save a workspace layout as a favourite — name, layout preset, and per-pane commands. Launch any favourite with a click to instantly recreate the setup.
53
53
 
54
+ ### GPU Rendering
55
+
56
+ Terminals render via WebGL2 on the GPU by default, which is significantly faster for high-throughput output and multiple panes. Toggle it from the sidebar header (lightning bolt icon) or the command palette. Falls back to the DOM renderer automatically if WebGL2 is unavailable or context is lost. The setting persists across sessions.
57
+
54
58
  ### Terminal Search
55
59
 
56
60
  Press `Cmd+F` in any focused terminal to search its scrollback. Navigate matches with Enter / Shift+Enter or the up/down buttons. Press Escape to close.
@@ -268,71 +268,168 @@ async function startServer(devMode, port) {
268
268
  res.json({ valid: false });
269
269
  }
270
270
  });
271
- // Resolve a dropped file's full path using OS file index (Spotlight on macOS)
271
+ // Resolve a dropped file's full path via tiered search (stat find → Spotlight)
272
+ const resolvePathCache = new Map();
273
+ const RESOLVE_CACHE_TTL = 30_000;
272
274
  app.post('/api/resolve-path', (req, res) => {
273
- const { name, size, lastModified } = req.body;
275
+ const { name, size, lastModified, cwd: hintCwd } = req.body;
274
276
  if (!name) {
275
277
  res.status(400).json({ error: 'name required' });
276
278
  return;
277
279
  }
278
- const findBest = (candidates) => {
279
- if (candidates.length === 0)
280
- return null;
281
- if (candidates.length === 1)
282
- return candidates[0];
283
- // Score candidates: exact size + mtime match wins, then size-only, then most recent
284
- let best = null;
285
- for (const candidate of candidates) {
280
+ const cacheKey = `${name}:${size ?? ''}:${lastModified ?? ''}`;
281
+ const cached = resolvePathCache.get(cacheKey);
282
+ if (cached && Date.now() - cached.ts < RESOLVE_CACHE_TTL) {
283
+ res.json({ path: cached.path, isDirectory: cached.isDirectory });
284
+ return;
285
+ }
286
+ const respond = (resolved) => {
287
+ let isDirectory = false;
288
+ if (resolved) {
286
289
  try {
287
- const stat = fs.statSync(candidate);
288
- let score = 0;
289
- if (size && stat.size === size)
290
- score += 10;
291
- if (lastModified && Math.abs(stat.mtimeMs - lastModified) < 2000)
292
- score += 5;
293
- // Exclude node_modules and hidden dirs to prefer "real" files
294
- if (!candidate.includes('node_modules') && !candidate.includes('/.'))
295
- score += 1;
296
- if (!best || score > best.score || (score === best.score && stat.mtimeMs > best.mtime)) {
297
- best = { path: candidate, score, mtime: stat.mtimeMs };
298
- }
290
+ isDirectory = fs.statSync(resolved).isDirectory();
299
291
  }
300
- catch { /* skip inaccessible */ }
292
+ catch { }
301
293
  }
302
- return best?.path ?? candidates[0];
294
+ resolvePathCache.set(cacheKey, { path: resolved, isDirectory, ts: Date.now() });
295
+ res.json({ path: resolved, isDirectory });
303
296
  };
304
- const respond = (resolved) => {
305
- if (!resolved) {
306
- res.json({ path: null });
307
- return;
308
- }
297
+ const homeDir = os.homedir();
298
+ const quickDirs = [
299
+ homeDir,
300
+ path.join(homeDir, 'Desktop'),
301
+ path.join(homeDir, 'Downloads'),
302
+ path.join(homeDir, 'Documents'),
303
+ path.join(homeDir, 'Developer'),
304
+ path.join(homeDir, 'Projects'),
305
+ path.join(homeDir, 'Source'),
306
+ path.join(homeDir, 'src'),
307
+ path.join(homeDir, 'code'),
308
+ path.join(homeDir, 'repos'),
309
+ path.join(homeDir, 'workspace'),
310
+ '/tmp',
311
+ ];
312
+ for (const proj of projectStore.list()) {
313
+ const parent = path.dirname(proj.cwd);
314
+ if (!quickDirs.includes(parent))
315
+ quickDirs.push(parent);
316
+ }
317
+ for (const dir of quickDirs) {
318
+ const candidate = path.join(dir, name);
309
319
  try {
310
- const isDirectory = fs.statSync(resolved).isDirectory();
311
- res.json({ path: resolved, isDirectory });
312
- }
313
- catch {
314
- res.json({ path: resolved, isDirectory: false });
315
- }
316
- };
317
- if (process.platform === 'darwin') {
318
- execFile('mdfind', [`kMDItemFSName == '${name.replace(/'/g, "\\'")}'`], (err, stdout) => {
319
- if (err) {
320
- respond(null);
320
+ const stat = fs.statSync(candidate);
321
+ if (!size || stat.size === size) {
322
+ respond(candidate);
321
323
  return;
322
324
  }
323
- const candidates = stdout.trim().split('\n').filter(Boolean);
324
- respond(findBest(candidates));
325
- });
325
+ }
326
+ catch { }
327
+ }
328
+ const projectCwds = hintCwd ? [hintCwd] : [];
329
+ for (const proj of projectStore.list()) {
330
+ if (proj.cwd !== hintCwd)
331
+ projectCwds.push(proj.cwd);
332
+ }
333
+ const subDirs = ['', 'server', 'src', 'lib', 'app', 'web', 'web/src', 'web/src/lib',
334
+ 'web/src/hooks', 'web/src/stores', 'web/src/components', 'test', 'tests', 'scripts'];
335
+ for (const cwd of projectCwds) {
336
+ for (const sub of subDirs) {
337
+ const candidate = sub ? path.join(cwd, sub, name) : path.join(cwd, name);
338
+ try {
339
+ const stat = fs.statSync(candidate);
340
+ if (!size || stat.size === size) {
341
+ respond(candidate);
342
+ return;
343
+ }
344
+ }
345
+ catch { }
346
+ }
347
+ }
348
+ const searchDirs = projectCwds;
349
+ if (searchDirs.length > 0) {
350
+ let found = false;
351
+ let pending = searchDirs.length;
352
+ for (const dir of searchDirs) {
353
+ execFile('find', [
354
+ dir, '-name', name, '-maxdepth', '10',
355
+ '-not', '-path', '*/node_modules/*',
356
+ '-not', '-path', '*/.*/*',
357
+ '-print', '-quit',
358
+ ], { timeout: 1000 }, (err, stdout) => {
359
+ pending--;
360
+ if (found)
361
+ return;
362
+ const match = stdout?.trim();
363
+ if (!err && match) {
364
+ try {
365
+ const stat = fs.statSync(match);
366
+ if (!size || stat.size === size) {
367
+ found = true;
368
+ respond(match);
369
+ return;
370
+ }
371
+ }
372
+ catch { }
373
+ }
374
+ if (pending === 0 && !found)
375
+ fallbackSearch();
376
+ });
377
+ }
326
378
  }
327
379
  else {
328
- execFile('locate', ['-l', '20', '-b', `\\${name}`], (err, stdout) => {
329
- if (err) {
330
- respond(null);
331
- return;
380
+ fallbackSearch();
381
+ }
382
+ function fallbackSearch() {
383
+ const findBest = (candidates) => {
384
+ if (candidates.length === 0)
385
+ return null;
386
+ if (candidates.length === 1)
387
+ return candidates[0];
388
+ const capped = candidates.slice(0, 20);
389
+ let best = null;
390
+ for (const candidate of capped) {
391
+ try {
392
+ const stat = fs.statSync(candidate);
393
+ let score = 0;
394
+ if (size && stat.size === size)
395
+ score += 10;
396
+ if (lastModified && Math.abs(stat.mtimeMs - lastModified) < 2000)
397
+ score += 5;
398
+ if (!candidate.includes('node_modules') && !candidate.includes('/.'))
399
+ score += 1;
400
+ if (!best || score > best.score || (score === best.score && stat.mtimeMs > best.mtime)) {
401
+ best = { path: candidate, score, mtime: stat.mtimeMs };
402
+ }
403
+ if (best.score >= 16)
404
+ break;
405
+ }
406
+ catch { }
332
407
  }
333
- const candidates = stdout.trim().split('\n').filter(Boolean);
334
- respond(findBest(candidates));
335
- });
408
+ return best?.path ?? candidates[0];
409
+ };
410
+ if (process.platform === 'darwin') {
411
+ execFile('mdfind', [
412
+ '-onlyin', homeDir,
413
+ `kMDItemFSName == '${name.replace(/'/g, "\\'")}'`,
414
+ ], { timeout: 3000 }, (err, stdout) => {
415
+ if (err) {
416
+ respond(null);
417
+ return;
418
+ }
419
+ const candidates = stdout.trim().split('\n').filter(Boolean);
420
+ respond(findBest(candidates));
421
+ });
422
+ }
423
+ else {
424
+ execFile('locate', ['-l', '20', '-b', `\\${name}`], { timeout: 3000 }, (err, stdout) => {
425
+ if (err) {
426
+ respond(null);
427
+ return;
428
+ }
429
+ const candidates = stdout.trim().split('\n').filter(Boolean);
430
+ respond(findBest(candidates));
431
+ });
432
+ }
336
433
  }
337
434
  });
338
435
  // Serve static frontend (production only)
@@ -158,6 +158,61 @@ func readLockfile() -> (pid: pid_t, port: Int)? {
158
158
  return (pid, port)
159
159
  }
160
160
 
161
+ class DropWebView: WKWebView {
162
+ override init(frame: CGRect, configuration: WKWebViewConfiguration) {
163
+ super.init(frame: frame, configuration: configuration)
164
+ registerForDraggedTypes([
165
+ .fileURL,
166
+ .URL,
167
+ .string,
168
+ NSPasteboard.PasteboardType("public.url"),
169
+ NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-url"),
170
+ NSPasteboard.PasteboardType("Apple files promise pasteboard type"),
171
+ ])
172
+ }
173
+ required init?(coder: NSCoder) { super.init(coder: coder) }
174
+
175
+ private func extractPaths(from pasteboard: NSPasteboard) -> [String] {
176
+ var paths: [String] = []
177
+ guard let items = pasteboard.pasteboardItems else { return paths }
178
+ for item in items {
179
+ // Try public.url first (file:// URI from VS Code)
180
+ if let urlStr = item.string(forType: NSPasteboard.PasteboardType("public.url")),
181
+ urlStr.hasPrefix("file://"),
182
+ let url = URL(string: urlStr) {
183
+ paths.append(url.path)
184
+ continue
185
+ }
186
+ // Fallback: plain text absolute path
187
+ if let text = item.string(forType: .string), text.hasPrefix("/") {
188
+ paths.append(text.trimmingCharacters(in: .whitespacesAndNewlines))
189
+ }
190
+ }
191
+ return paths
192
+ }
193
+
194
+ override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
195
+ return extractPaths(from: sender.draggingPasteboard).isEmpty
196
+ ? super.draggingEntered(sender) : .copy
197
+ }
198
+
199
+ override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
200
+ let paths = extractPaths(from: sender.draggingPasteboard)
201
+ if !paths.isEmpty {
202
+ if let json = try? JSONSerialization.data(withJSONObject: paths),
203
+ let jsonStr = String(data: json, encoding: .utf8) {
204
+ // Convert AppKit coordinates (origin bottom-left) to web coordinates (origin top-left)
205
+ let loc = sender.draggingLocation
206
+ let x = loc.x
207
+ let y = bounds.height - loc.y
208
+ evaluateJavaScript("window.__panefulHandleDrop && window.__panefulHandleDrop(\\(jsonStr), \\(x), \\(y))") { _, _ in }
209
+ }
210
+ return true
211
+ }
212
+ return super.performDragOperation(sender)
213
+ }
214
+ }
215
+
161
216
  class AppDelegate: NSObject, NSApplicationDelegate {
162
217
  var window: NSWindow!
163
218
  var webView: WKWebView!
@@ -244,7 +299,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
244
299
  let config = WKWebViewConfiguration()
245
300
  config.preferences.setValue(true, forKey: "developerExtrasEnabled")
246
301
 
247
- webView = WKWebView(frame: .zero, configuration: config)
302
+ webView = DropWebView(frame: .zero, configuration: config)
248
303
  webView.load(URLRequest(url: URL(string: "http://localhost:\\(self.port)")!))
249
304
 
250
305
  window = NSWindow(
@@ -1,4 +1,5 @@
1
1
  import * as pty from 'node-pty';
2
+ import { execSync } from 'node:child_process';
2
3
  import os from 'node:os';
3
4
  export class PtyManager {
4
5
  sessions = new Map();
@@ -78,7 +79,9 @@ export class PtyManager {
78
79
  for (const [terminalId, managed] of this.sessions) {
79
80
  try {
80
81
  const proc = managed.process.process;
81
- if (proc === 'claude' || proc.startsWith('codex')) {
82
+ const isAgent = proc === 'claude' || proc === 'aider' || proc.startsWith('codex')
83
+ || (RUNTIME_PROCESSES.has(proc) && this.checkChildCmdline(managed.process.pid));
84
+ if (isAgent) {
82
85
  const list = result.get(managed.projectId);
83
86
  if (list)
84
87
  list.push(terminalId);
@@ -92,4 +95,25 @@ export class PtyManager {
92
95
  }
93
96
  return result;
94
97
  }
98
+ /** Check if any child process of the shell is a known AI agent. */
99
+ checkChildCmdline(shellPid) {
100
+ try {
101
+ const childPids = execSync(`pgrep -P ${shellPid}`, { encoding: 'utf8', timeout: 1000 })
102
+ .trim().split('\n').filter(Boolean);
103
+ if (childPids.length === 0)
104
+ return false;
105
+ for (const pid of childPids) {
106
+ const cmdline = execSync(`ps -o args= -p ${pid}`, { encoding: 'utf8', timeout: 1000 }).trim();
107
+ if (AGENT_CMD_PATTERN.test(cmdline))
108
+ return true;
109
+ }
110
+ return false;
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ }
95
116
  }
117
+ const RUNTIME_PROCESSES = new Set(['node', 'python', 'python3']);
118
+ // Match agent binary names at the end of a path or as a standalone token
119
+ const AGENT_CMD_PATTERN = /(?:^|\/)(codex|claude|aider)(?:\s|$)/;
@@ -152,6 +152,15 @@ export class WsHandler {
152
152
  this.projectStore.remove(msg.projectId);
153
153
  break;
154
154
  }
155
+ case 'open:url': {
156
+ const url = msg.url;
157
+ if (/^https?:\/\//i.test(url)) {
158
+ import('open').then(({ default: open }) => {
159
+ open(url).catch(() => { });
160
+ });
161
+ }
162
+ break;
163
+ }
155
164
  }
156
165
  }
157
166
  getEditorState() {