paneful 0.8.6 → 0.8.8
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 +12 -8
- package/dist/server/claude-monitor.js +106 -0
- package/dist/server/port-monitor.js +33 -18
- package/dist/server/pty-manager.js +16 -0
- package/dist/server/ws-handler.js +7 -0
- package/dist/web/assets/{index-D9fr7DHS.js → index-CTFgZeZH.js} +56 -56
- package/dist/web/assets/{index-BtMt9GQ0.css → index-CYCoL-NZ.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,17 +26,17 @@ paneful --install-app # Install as a native macOS app
|
|
|
26
26
|
|
|
27
27
|
## Features
|
|
28
28
|
|
|
29
|
-
###
|
|
29
|
+
### Split Pane Layouts
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
Five layout presets — columns, rows, main + stack, main + row, and grid. Cycle through them with `Cmd+T` or auto-reorganize with `Cmd+R`.
|
|
32
32
|
|
|
33
|
-
###
|
|
33
|
+
### Project Sidebar
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
Organize terminals by project. Each project gets its own layout, panes, and working directory. Switch instantly from the sidebar. Drag the right edge to resize — width persists across sessions.
|
|
36
36
|
|
|
37
|
-
###
|
|
37
|
+
### Drag & Drop
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
Drag folders from Finder into the sidebar to create projects with the path pre-filled. Drag files into terminal panes to paste their paths as shell-escaped arguments.
|
|
40
40
|
|
|
41
41
|
### Editor Sync
|
|
42
42
|
|
|
@@ -47,9 +47,13 @@ Requires:
|
|
|
47
47
|
1. **Paneful** (native app) or **Terminal** (CLI) added to **System Settings > Privacy & Security > Accessibility**
|
|
48
48
|
2. Editor window title includes the folder name (default in VS Code/Cursor)
|
|
49
49
|
|
|
50
|
-
###
|
|
50
|
+
### Favourites
|
|
51
|
+
|
|
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
|
+
|
|
54
|
+
### Dev Server Detection
|
|
51
55
|
|
|
52
|
-
|
|
56
|
+
Automatically detects when a dev server starts in a terminal (Vite, Next.js, Angular, etc.). A green dot appears next to the project name in the sidebar while the port is alive, and disappears when it stops. Tracks ports per-terminal so the same port across different projects is handled correctly.
|
|
53
57
|
|
|
54
58
|
### Auto-Reorganize
|
|
55
59
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
// If the latest JSONL mtime is < 5s ago, Claude is actively working.
|
|
5
|
+
// Otherwise Claude is open but idle (waiting for user input).
|
|
6
|
+
const ACTIVE_THRESHOLD = 5_000;
|
|
7
|
+
export class ClaudeMonitor {
|
|
8
|
+
claudeDir;
|
|
9
|
+
ptyManager;
|
|
10
|
+
projectStore;
|
|
11
|
+
onChange;
|
|
12
|
+
prevStatuses = {};
|
|
13
|
+
cachedLatestFile = new Map(); // cwd → latest .jsonl path
|
|
14
|
+
pollTimer = null;
|
|
15
|
+
destroyed = false;
|
|
16
|
+
constructor(ptyManager, projectStore, onChange) {
|
|
17
|
+
this.ptyManager = ptyManager;
|
|
18
|
+
this.projectStore = projectStore;
|
|
19
|
+
this.onChange = onChange;
|
|
20
|
+
this.claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
|
21
|
+
}
|
|
22
|
+
start() {
|
|
23
|
+
this.pollTimer = setInterval(() => this.poll(), 3000);
|
|
24
|
+
}
|
|
25
|
+
destroy() {
|
|
26
|
+
this.destroyed = true;
|
|
27
|
+
if (this.pollTimer) {
|
|
28
|
+
clearInterval(this.pollTimer);
|
|
29
|
+
this.pollTimer = null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
poll() {
|
|
33
|
+
if (this.destroyed)
|
|
34
|
+
return;
|
|
35
|
+
// Step 1: Ask pty-manager which projects have `claude` as foreground process
|
|
36
|
+
const claudeProjects = this.ptyManager.getClaudeProjects();
|
|
37
|
+
// Step 2: For each, determine active vs idle by checking JSONL mtime
|
|
38
|
+
const statuses = {};
|
|
39
|
+
for (const projectId of claudeProjects) {
|
|
40
|
+
const project = this.projectStore.get(projectId);
|
|
41
|
+
if (!project)
|
|
42
|
+
continue;
|
|
43
|
+
const status = this.checkMtime(project.cwd) ? 'active' : 'idle';
|
|
44
|
+
statuses[projectId] = status;
|
|
45
|
+
}
|
|
46
|
+
// Step 3: Only notify if something changed
|
|
47
|
+
if (!this.statusesEqual(statuses)) {
|
|
48
|
+
this.prevStatuses = statuses;
|
|
49
|
+
this.onChange(statuses);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Returns true if the project's latest JSONL was modified within ACTIVE_THRESHOLD. */
|
|
53
|
+
checkMtime(cwd) {
|
|
54
|
+
// Fast path: stat only the cached latest file
|
|
55
|
+
const cached = this.cachedLatestFile.get(cwd);
|
|
56
|
+
if (cached) {
|
|
57
|
+
try {
|
|
58
|
+
const stat = fs.statSync(cached);
|
|
59
|
+
if ((Date.now() - stat.mtimeMs) < ACTIVE_THRESHOLD)
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// File gone — fall through to full scan
|
|
64
|
+
this.cachedLatestFile.delete(cwd);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Full scan: find the latest .jsonl (runs once on first check, then only
|
|
68
|
+
// when the cached file is stale — i.e. Claude started a new session)
|
|
69
|
+
const folder = path.join(this.claudeDir, cwd.replace(/\//g, '-'));
|
|
70
|
+
try {
|
|
71
|
+
const files = fs.readdirSync(folder).filter((f) => f.endsWith('.jsonl'));
|
|
72
|
+
let maxMtime = 0;
|
|
73
|
+
let maxFile = '';
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
try {
|
|
76
|
+
const filePath = path.join(folder, file);
|
|
77
|
+
const stat = fs.statSync(filePath);
|
|
78
|
+
if (stat.mtimeMs > maxMtime) {
|
|
79
|
+
maxMtime = stat.mtimeMs;
|
|
80
|
+
maxFile = filePath;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// skip
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (maxFile)
|
|
88
|
+
this.cachedLatestFile.set(cwd, maxFile);
|
|
89
|
+
return maxMtime > 0 && (Date.now() - maxMtime) < ACTIVE_THRESHOLD;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
statusesEqual(next) {
|
|
96
|
+
const prevKeys = Object.keys(this.prevStatuses);
|
|
97
|
+
const nextKeys = Object.keys(next);
|
|
98
|
+
if (prevKeys.length !== nextKeys.length)
|
|
99
|
+
return false;
|
|
100
|
+
for (const key of nextKeys) {
|
|
101
|
+
if (this.prevStatuses[key] !== next[key])
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import net from
|
|
1
|
+
import net from "node:net";
|
|
2
2
|
// Strip ANSI escape codes from PTY output
|
|
3
3
|
const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?(?:\x07|\x1b\\)|\x1b[()][0-9A-B]/g;
|
|
4
4
|
// Match dev-server URLs like http://localhost:3000, http://127.0.0.1:8080, etc.
|
|
@@ -18,7 +18,7 @@ export class PortMonitor {
|
|
|
18
18
|
return;
|
|
19
19
|
let info = this.terminals.get(terminalId);
|
|
20
20
|
if (!info) {
|
|
21
|
-
info = { projectId, ports: new Set(), lineBuffer:
|
|
21
|
+
info = { projectId, ports: new Set(), lineBuffer: "" };
|
|
22
22
|
this.terminals.set(terminalId, info);
|
|
23
23
|
}
|
|
24
24
|
// Already found ports for this terminal — skip scanning.
|
|
@@ -27,11 +27,11 @@ export class PortMonitor {
|
|
|
27
27
|
if (info.ports.size > 0)
|
|
28
28
|
return;
|
|
29
29
|
// Strip ANSI codes from just the new chunk, then append to line buffer
|
|
30
|
-
const clean = data.replace(ANSI_RE,
|
|
30
|
+
const clean = data.replace(ANSI_RE, "");
|
|
31
31
|
const combined = info.lineBuffer + clean;
|
|
32
32
|
const lines = combined.split(/\r?\n/);
|
|
33
33
|
// Keep last incomplete line in buffer, capped to prevent unbounded growth
|
|
34
|
-
const tail = lines.pop() ??
|
|
34
|
+
const tail = lines.pop() ?? "";
|
|
35
35
|
info.lineBuffer = tail.length > 512 ? tail.slice(-512) : tail;
|
|
36
36
|
let found = false;
|
|
37
37
|
for (const line of lines) {
|
|
@@ -111,20 +111,35 @@ export class PortMonitor {
|
|
|
111
111
|
}
|
|
112
112
|
const probeResults = new Map();
|
|
113
113
|
await Promise.all([...uniquePorts].map((port) => new Promise((resolve) => {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
114
|
+
// Try IPv4 first, fall back to IPv6 (::1).
|
|
115
|
+
// Many dev servers (Angular/Vite) bind to ::1 on macOS.
|
|
116
|
+
const tryConnect = (host, fallback) => {
|
|
117
|
+
const sock = net.createConnection({ port, host }, () => {
|
|
118
|
+
probeResults.set(port, true);
|
|
119
|
+
sock.destroy();
|
|
120
|
+
resolve();
|
|
121
|
+
});
|
|
122
|
+
sock.on("error", () => {
|
|
123
|
+
if (fallback) {
|
|
124
|
+
tryConnect(fallback);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
probeResults.set(port, false);
|
|
128
|
+
resolve();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
sock.setTimeout(2000, () => {
|
|
132
|
+
sock.destroy();
|
|
133
|
+
if (fallback) {
|
|
134
|
+
tryConnect(fallback);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
probeResults.set(port, false);
|
|
138
|
+
resolve();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
tryConnect("127.0.0.1", "::1");
|
|
128
143
|
})));
|
|
129
144
|
if (this.destroyed)
|
|
130
145
|
return;
|
|
@@ -72,4 +72,20 @@ export class PtyManager {
|
|
|
72
72
|
terminalExists(terminalId) {
|
|
73
73
|
return this.sessions.has(terminalId);
|
|
74
74
|
}
|
|
75
|
+
/** Returns projectIds that have a terminal with `claude` as the foreground process. */
|
|
76
|
+
getClaudeProjects() {
|
|
77
|
+
const result = new Set();
|
|
78
|
+
for (const managed of this.sessions.values()) {
|
|
79
|
+
try {
|
|
80
|
+
const proc = managed.process.process;
|
|
81
|
+
if (proc === 'claude') {
|
|
82
|
+
result.add(managed.projectId);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// PTY may have been killed
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
75
91
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
2
2
|
import { newProject } from './project-store.js';
|
|
3
3
|
import { PortMonitor } from './port-monitor.js';
|
|
4
|
+
import { ClaudeMonitor } from './claude-monitor.js';
|
|
4
5
|
export class WsHandler {
|
|
5
6
|
wss;
|
|
6
7
|
client = null;
|
|
7
8
|
ptyManager;
|
|
8
9
|
projectStore;
|
|
9
10
|
portMonitor;
|
|
11
|
+
claudeMonitor;
|
|
10
12
|
idleTimer = null;
|
|
11
13
|
onIdle;
|
|
12
14
|
constructor(server, ptyManager, projectStore, options) {
|
|
@@ -16,6 +18,10 @@ export class WsHandler {
|
|
|
16
18
|
this.portMonitor = new PortMonitor((ports) => {
|
|
17
19
|
this.send({ type: 'port:status', ports });
|
|
18
20
|
});
|
|
21
|
+
this.claudeMonitor = new ClaudeMonitor(ptyManager, projectStore, (statuses) => {
|
|
22
|
+
this.send({ type: 'claude:status', statuses });
|
|
23
|
+
});
|
|
24
|
+
this.claudeMonitor.start();
|
|
19
25
|
this.wss = new WebSocketServer({ noServer: true });
|
|
20
26
|
server.on('upgrade', (req, socket, head) => {
|
|
21
27
|
if (req.url === '/ws') {
|
|
@@ -119,6 +125,7 @@ export class WsHandler {
|
|
|
119
125
|
}
|
|
120
126
|
destroy() {
|
|
121
127
|
this.portMonitor.destroy();
|
|
128
|
+
this.claudeMonitor.destroy();
|
|
122
129
|
}
|
|
123
130
|
handlePtySpawn(terminalId, projectId, cwd) {
|
|
124
131
|
try {
|