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 +6 -2
- package/dist/server/index.js +147 -50
- package/dist/server/install-app.js +56 -1
- package/dist/server/pty-manager.js +25 -1
- package/dist/server/ws-handler.js +9 -0
- package/dist/web/assets/index-BkzI6Ty0.js +515 -0
- package/dist/web/assets/{index-CKAjkmp4.css → index-Bmd9Quiw.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DCCBoZoh.js +0 -434
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Paneful
|
|
2
2
|
|
|
3
|
-
A terminal multiplexer that runs in your browser
|
|
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
|
-

|
|
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.
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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 {
|
|
292
|
+
catch { }
|
|
301
293
|
}
|
|
302
|
-
|
|
294
|
+
resolvePathCache.set(cacheKey, { path: resolved, isDirectory, ts: Date.now() });
|
|
295
|
+
res.json({ path: resolved, isDirectory });
|
|
303
296
|
};
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
311
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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 =
|
|
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
|
-
|
|
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() {
|