paneful 0.9.0 → 0.9.2

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
@@ -51,10 +51,30 @@ 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
+ ### Terminal Search
55
+
56
+ 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.
57
+
58
+ ### Command Palette
59
+
60
+ Press `Cmd+P` to open the command palette. Quickly switch projects, launch favourites, change layouts, or run any action — all from one fuzzy-searchable list.
61
+
62
+ ### Git Branch Display
63
+
64
+ The sidebar shows the current Git branch next to each project's working directory as a small pill badge. Updates automatically every 10 seconds. Non-git directories show no badge.
65
+
66
+ ### AI Agent Detection
67
+
68
+ Automatically detects when Claude Code or Codex CLI is running in a Paneful terminal. A purple **AI** badge appears next to the project name in the sidebar — pulsing when the agent is actively working, dimmed when idle. Disappears instantly when the agent exits. Uses zero filesystem access; detection is purely in-memory via the PTY process name and terminal output timestamps.
69
+
54
70
  ### Dev Server Detection
55
71
 
56
72
  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.
57
73
 
74
+ ### Project Cleanup
75
+
76
+ Click the broom icon in the sidebar header to scan for projects whose directories no longer exist on disk. A confirmation modal shows matching projects before removing them.
77
+
58
78
  ### Auto-Reorganize
59
79
 
60
80
  Press `Cmd+R` or click the dashboard icon in the toolbar to automatically pick the best layout for your current pane count.
@@ -85,6 +105,8 @@ Paneful checks for newer versions on npm and shows a notification in the sidebar
85
105
 
86
106
  | Shortcut | Action |
87
107
  | ------------------ | ------------------------------- |
108
+ | `Cmd+P` | Command palette |
109
+ | `Cmd+F` | Search terminal scrollback |
88
110
  | `Cmd+N` | New pane (vertical split) |
89
111
  | `Cmd+Shift+N` | New pane (horizontal split) |
90
112
  | `Cmd+W` | Close focused pane |
@@ -11,9 +11,24 @@ export class ClaudeMonitor {
11
11
  this.ptyManager = ptyManager;
12
12
  this.onChange = onChange;
13
13
  }
14
- start() {
14
+ resume() {
15
+ if (this.destroyed || this.pollTimer)
16
+ return;
15
17
  this.pollTimer = setInterval(() => this.poll(), 3000);
16
18
  }
19
+ pause() {
20
+ if (this.pollTimer) {
21
+ clearInterval(this.pollTimer);
22
+ this.pollTimer = null;
23
+ }
24
+ }
25
+ getStatuses() {
26
+ return { ...this.prevStatuses };
27
+ }
28
+ /** @deprecated Use resume() instead */
29
+ start() {
30
+ this.resume();
31
+ }
17
32
  /** Call from the PTY output path to record activity. */
18
33
  recordOutput(terminalId) {
19
34
  this.lastOutput.set(terminalId, Date.now());
@@ -0,0 +1,394 @@
1
+ import { spawn, execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { homedir, tmpdir } from 'node:os';
6
+ const EDITOR_PATTERNS = ['cursor', 'code', 'vscode', 'visual studio code', 'zed', 'windsurf', 'electron'];
7
+ // Long-lived osascript fallback — same as before.
8
+ const STREAMING_SCRIPT = `
9
+ repeat
10
+ try
11
+ tell application "System Events"
12
+ set frontApp to name of first application process whose frontmost is true
13
+ set winTitle to ""
14
+ tell process frontApp
15
+ if exists front window then
16
+ set winTitle to name of front window
17
+ end if
18
+ end tell
19
+ end tell
20
+ log frontApp & "\\t" & winTitle
21
+ end try
22
+ delay 0.75
23
+ end repeat
24
+ `;
25
+ // Swift helper: event-driven app switches via NSWorkspace, AX observer for window/title changes.
26
+ const SWIFT_SOURCE = `
27
+ import Cocoa
28
+
29
+ let editorPatterns: [String] = ["cursor", "code", "vscode", "visual studio code", "zed", "windsurf", "electron"]
30
+
31
+ func isEditor(_ name: String) -> Bool {
32
+ let lower = name.lowercased()
33
+ return editorPatterns.contains { lower.contains($0) }
34
+ }
35
+
36
+ func getWindowTitle(pid: pid_t) -> String {
37
+ let app = AXUIElementCreateApplication(pid)
38
+ var value: AnyObject?
39
+ guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &value) == .success else {
40
+ return ""
41
+ }
42
+ var title: AnyObject?
43
+ guard AXUIElementCopyAttributeValue(value as! AXUIElement, kAXTitleAttribute as CFString, &title) == .success else {
44
+ return ""
45
+ }
46
+ return title as? String ?? ""
47
+ }
48
+
49
+ setbuf(stdout, nil)
50
+ setbuf(stderr, nil)
51
+
52
+ let startTime = CFAbsoluteTimeGetCurrent()
53
+ func log(_ msg: String) {
54
+ let elapsed = String(format: "%.3f", CFAbsoluteTimeGetCurrent() - startTime)
55
+ fputs("[swift +\\(elapsed)s] \\(msg)\\n", stderr)
56
+ }
57
+
58
+ var lastOutput = ""
59
+ var currentPid: pid_t = 0
60
+ var currentAppName = ""
61
+ var axObserver: AXObserver?
62
+ var observedWindow: AXUIElement?
63
+ var fallbackTimer: Timer?
64
+
65
+ func emit(_ appName: String, _ title: String, source: String) {
66
+ let output = "\\(appName)\\t\\(title)"
67
+ guard output != lastOutput else { return }
68
+ lastOutput = output
69
+ log("emit (\\(source)): \\(appName) | \\(title.isEmpty ? "(no title)" : title)")
70
+ print(output)
71
+ }
72
+
73
+ func emitCurrentTitle(source: String) {
74
+ let title = getWindowTitle(pid: currentPid)
75
+ emit(currentAppName, title, source: source)
76
+ }
77
+
78
+ let axCallback: AXObserverCallback = { _, _, notification, _ in
79
+ let name = notification as String
80
+ if name == (kAXFocusedWindowChangedNotification as String) {
81
+ log("AX: focused window changed")
82
+ observeFocusedWindowTitle()
83
+ emitCurrentTitle(source: "ax-window-change")
84
+ } else if name == (kAXTitleChangedNotification as String) {
85
+ log("AX: title changed")
86
+ emitCurrentTitle(source: "ax-title-change")
87
+ } else {
88
+ emitCurrentTitle(source: "ax-\\(name)")
89
+ }
90
+ }
91
+
92
+ func observeFocusedWindowTitle() {
93
+ guard let obs = axObserver else { return }
94
+ if let old = observedWindow {
95
+ AXObserverRemoveNotification(obs, old, kAXTitleChangedNotification as CFString)
96
+ observedWindow = nil
97
+ }
98
+ let app = AXUIElementCreateApplication(currentPid)
99
+ var winValue: AnyObject?
100
+ guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &winValue) == .success else { return }
101
+ let window = winValue as! AXUIElement
102
+ AXObserverAddNotification(obs, window, kAXTitleChangedNotification as CFString, nil)
103
+ observedWindow = window
104
+ }
105
+
106
+ func startObserving(pid: pid_t) {
107
+ stopObserving()
108
+ emitCurrentTitle(source: "editor-activated")
109
+
110
+ var observer: AXObserver?
111
+ guard AXObserverCreate(pid, axCallback, &observer) == .success, let obs = observer else {
112
+ log("AXObserver failed for pid \\(pid), using poll fallback")
113
+ fallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
114
+ emitCurrentTitle(source: "poll")
115
+ }
116
+ return
117
+ }
118
+
119
+ let appElement = AXUIElementCreateApplication(pid)
120
+ AXObserverAddNotification(obs, appElement, kAXFocusedWindowChangedNotification as CFString, nil)
121
+ CFRunLoopAddSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(obs), .defaultMode)
122
+ axObserver = obs
123
+ observeFocusedWindowTitle()
124
+ log("AXObserver active for pid \\(pid)")
125
+
126
+ // AX notifications don't fire for Electron apps (Cursor, VS Code, etc.)
127
+ // so this timer is the primary mechanism for those editors
128
+ fallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
129
+ emitCurrentTitle(source: "poll")
130
+ }
131
+ }
132
+
133
+ func stopObserving() {
134
+ if let obs = axObserver {
135
+ CFRunLoopRemoveSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(obs), .defaultMode)
136
+ axObserver = nil
137
+ observedWindow = nil
138
+ }
139
+ fallbackTimer?.invalidate()
140
+ fallbackTimer = nil
141
+ }
142
+
143
+ let nc = NSWorkspace.shared.notificationCenter
144
+ nc.addObserver(forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main) { notif in
145
+ guard let app = notif.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
146
+ let name = app.localizedName else { return }
147
+ log("app switch: \\(name)")
148
+ currentAppName = name
149
+ currentPid = app.processIdentifier
150
+ if isEditor(name) {
151
+ startObserving(pid: currentPid)
152
+ } else {
153
+ stopObserving()
154
+ emit(name, "", source: "app-switch")
155
+ }
156
+ }
157
+
158
+ if let front = NSWorkspace.shared.frontmostApplication, let name = front.localizedName {
159
+ log("seed: \\(name)")
160
+ currentAppName = name
161
+ currentPid = front.processIdentifier
162
+ if isEditor(name) {
163
+ startObserving(pid: currentPid)
164
+ } else {
165
+ emit(name, "", source: "seed")
166
+ }
167
+ }
168
+
169
+ RunLoop.main.run()
170
+ `;
171
+ export class EditorMonitor {
172
+ onChange;
173
+ proc = null;
174
+ destroyed = false;
175
+ lineBuffer = '';
176
+ cache = { projectName: null };
177
+ restartTimer = null;
178
+ mode = 'osascript';
179
+ compiling = false;
180
+ constructor(onChange) {
181
+ this.onChange = onChange;
182
+ }
183
+ getState() {
184
+ return this.cache;
185
+ }
186
+ get helperPath() {
187
+ return join(homedir(), '.paneful', 'paneful-editor-helper');
188
+ }
189
+ get versionPath() {
190
+ return this.helperPath + '.version';
191
+ }
192
+ sourceHash() {
193
+ return createHash('sha256').update(SWIFT_SOURCE).digest('hex').slice(0, 16);
194
+ }
195
+ isHelperCurrent() {
196
+ if (!existsSync(this.helperPath))
197
+ return false;
198
+ try {
199
+ const stored = readFileSync(this.versionPath, 'utf-8').trim();
200
+ return stored === this.sourceHash();
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ }
206
+ async compileHelper() {
207
+ if (this.compiling)
208
+ return;
209
+ this.compiling = true;
210
+ const dir = join(homedir(), '.paneful');
211
+ mkdirSync(dir, { recursive: true });
212
+ const tmpSrc = join(tmpdir(), `paneful-editor-helper-${process.pid}.swift`);
213
+ writeFileSync(tmpSrc, SWIFT_SOURCE);
214
+ try {
215
+ await new Promise((resolve, reject) => {
216
+ const child = execFile('swiftc', [tmpSrc, '-o', this.helperPath, '-framework', 'Cocoa', '-O'], { timeout: 60_000 }, (err) => (err ? reject(err) : resolve()));
217
+ child.unref();
218
+ });
219
+ writeFileSync(this.versionPath, this.sourceHash());
220
+ }
221
+ finally {
222
+ this.compiling = false;
223
+ try {
224
+ unlinkSync(tmpSrc);
225
+ }
226
+ catch { }
227
+ }
228
+ }
229
+ resume() {
230
+ if (this.destroyed || this.proc)
231
+ return;
232
+ if (process.platform !== 'darwin')
233
+ return;
234
+ if (this.isHelperCurrent()) {
235
+ this.mode = 'native';
236
+ console.log('[editor-monitor] native helper is current, starting directly');
237
+ this.startProcess();
238
+ }
239
+ else {
240
+ this.mode = 'osascript';
241
+ console.log('[editor-monitor] native helper not found, starting osascript fallback');
242
+ this.startProcess();
243
+ console.log('[editor-monitor] compiling native helper in background...');
244
+ this.compileHelper()
245
+ .then(() => {
246
+ if (this.destroyed)
247
+ return;
248
+ console.log('[editor-monitor] compilation done, hot-swapping to native helper');
249
+ this.stopProcess();
250
+ this.mode = 'native';
251
+ this.startProcess();
252
+ })
253
+ .catch((err) => {
254
+ console.log(`[editor-monitor] compilation failed, keeping osascript: ${err.message}`);
255
+ });
256
+ }
257
+ }
258
+ pause() {
259
+ this.stopProcess();
260
+ }
261
+ destroy() {
262
+ this.destroyed = true;
263
+ this.stopProcess();
264
+ }
265
+ startProcess() {
266
+ if (this.destroyed || this.proc)
267
+ return;
268
+ let proc;
269
+ if (this.mode === 'native') {
270
+ proc = spawn(this.helperPath, [], {
271
+ stdio: ['ignore', 'pipe', 'pipe'],
272
+ });
273
+ this.proc = proc;
274
+ this.lineBuffer = '';
275
+ proc.stdout.on('data', (chunk) => {
276
+ if (this.destroyed)
277
+ return;
278
+ this.feedData(chunk);
279
+ });
280
+ // Forward Swift helper's debug logs to server console
281
+ let stderrBuf = '';
282
+ proc.stderr.on('data', (chunk) => {
283
+ stderrBuf += chunk.toString();
284
+ const lines = stderrBuf.split('\n');
285
+ stderrBuf = lines.pop() ?? '';
286
+ for (const line of lines) {
287
+ if (line.trim())
288
+ console.log(`[editor-monitor] ${line.trim()}`);
289
+ }
290
+ });
291
+ }
292
+ else {
293
+ proc = spawn('osascript', ['-e', STREAMING_SCRIPT], {
294
+ stdio: ['ignore', 'ignore', 'pipe'],
295
+ });
296
+ this.proc = proc;
297
+ this.lineBuffer = '';
298
+ proc.stderr.on('data', (chunk) => {
299
+ if (this.destroyed)
300
+ return;
301
+ this.feedData(chunk);
302
+ });
303
+ }
304
+ const currentMode = this.mode;
305
+ proc.on('error', (err) => {
306
+ if (this.proc !== proc)
307
+ return;
308
+ const msg = err.message || '';
309
+ const needsAccess = msg.includes('not allowed assistive access') || msg.includes('1719');
310
+ this.cache = { projectName: null, needsAccessibility: needsAccess || undefined };
311
+ this.proc = null;
312
+ if (currentMode === 'native') {
313
+ console.log(`[editor-monitor] native helper error, falling back to osascript: ${msg}`);
314
+ this.mode = 'osascript';
315
+ }
316
+ this.scheduleRestart();
317
+ });
318
+ proc.on('exit', (code) => {
319
+ if (this.proc !== proc)
320
+ return;
321
+ this.proc = null;
322
+ if (currentMode === 'native' && code !== 0) {
323
+ console.log(`[editor-monitor] native helper exited (code ${code}), falling back to osascript`);
324
+ this.mode = 'osascript';
325
+ }
326
+ this.scheduleRestart();
327
+ });
328
+ }
329
+ feedData(chunk) {
330
+ this.lineBuffer += chunk.toString();
331
+ const lines = this.lineBuffer.split('\n');
332
+ this.lineBuffer = lines.pop() ?? '';
333
+ for (const line of lines) {
334
+ this.handleLine(line.trim());
335
+ }
336
+ }
337
+ stopProcess() {
338
+ if (this.restartTimer) {
339
+ clearTimeout(this.restartTimer);
340
+ this.restartTimer = null;
341
+ }
342
+ if (this.proc) {
343
+ this.proc.kill();
344
+ this.proc = null;
345
+ }
346
+ }
347
+ scheduleRestart() {
348
+ if (this.destroyed)
349
+ return;
350
+ if (this.restartTimer)
351
+ return;
352
+ this.restartTimer = setTimeout(() => {
353
+ this.restartTimer = null;
354
+ if (!this.destroyed && !this.proc) {
355
+ this.startProcess();
356
+ }
357
+ }, 5000);
358
+ }
359
+ handleLine(line) {
360
+ if (!line)
361
+ return;
362
+ const tabIdx = line.indexOf('\t');
363
+ const appName = tabIdx >= 0 ? line.slice(0, tabIdx) : line;
364
+ const title = tabIdx >= 0 ? line.slice(tabIdx + 1) : '';
365
+ const isEditor = EDITOR_PATTERNS.some((pat) => appName.toLowerCase().includes(pat));
366
+ if (!isEditor || !title) {
367
+ this.cache = { projectName: null };
368
+ return;
369
+ }
370
+ let projectName = null;
371
+ const pathMatch = title.match(/^(~?\/[^\s]+)/);
372
+ if (pathMatch) {
373
+ const segments = pathMatch[1].replace(/\/$/, '').split('/');
374
+ projectName = segments[segments.length - 1] || null;
375
+ }
376
+ if (!projectName) {
377
+ const parts = title.split(' \u2014 ');
378
+ if (parts.length >= 3) {
379
+ projectName = parts[parts.length - 2];
380
+ }
381
+ else if (parts.length === 2) {
382
+ projectName = parts[0];
383
+ }
384
+ else {
385
+ projectName = title;
386
+ }
387
+ }
388
+ const prev = this.cache.projectName;
389
+ this.cache = { projectName };
390
+ if (projectName && projectName !== prev) {
391
+ this.onChange(projectName);
392
+ }
393
+ }
394
+ }
@@ -0,0 +1,98 @@
1
+ import { execFile } from 'node:child_process';
2
+ export class GitMonitor {
3
+ projectStore;
4
+ onChange;
5
+ branches = new Map();
6
+ pollTimer = null;
7
+ destroyed = false;
8
+ polling = false;
9
+ constructor(projectStore, onChange) {
10
+ this.projectStore = projectStore;
11
+ this.onChange = onChange;
12
+ // Initial poll to have data ready for first client connection
13
+ this.poll();
14
+ }
15
+ resume() {
16
+ if (this.destroyed || this.pollTimer)
17
+ return;
18
+ this.poll();
19
+ this.pollTimer = setInterval(() => this.poll(), 10_000);
20
+ }
21
+ pause() {
22
+ if (this.pollTimer) {
23
+ clearInterval(this.pollTimer);
24
+ this.pollTimer = null;
25
+ }
26
+ }
27
+ getBranches() {
28
+ const result = {};
29
+ for (const [id, branch] of this.branches) {
30
+ result[id] = branch;
31
+ }
32
+ return result;
33
+ }
34
+ destroy() {
35
+ this.destroyed = true;
36
+ if (this.pollTimer) {
37
+ clearInterval(this.pollTimer);
38
+ this.pollTimer = null;
39
+ }
40
+ this.branches.clear();
41
+ }
42
+ async poll() {
43
+ if (this.destroyed || this.polling)
44
+ return;
45
+ this.polling = true;
46
+ try {
47
+ await this.doPoll();
48
+ }
49
+ finally {
50
+ this.polling = false;
51
+ }
52
+ }
53
+ async doPoll() {
54
+ if (this.destroyed)
55
+ return;
56
+ const projects = this.projectStore.list();
57
+ const results = await Promise.all(projects.map((p) => this.getBranch(p.cwd).then((branch) => [p.id, branch])));
58
+ if (this.destroyed)
59
+ return;
60
+ const newBranches = new Map();
61
+ for (const [id, branch] of results) {
62
+ newBranches.set(id, branch);
63
+ }
64
+ // Check if changed
65
+ if (this.mapsEqual(newBranches))
66
+ return;
67
+ this.branches = newBranches;
68
+ this.notify();
69
+ }
70
+ getBranch(cwd) {
71
+ return new Promise((resolve) => {
72
+ execFile('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeout: 2000 }, (err, stdout) => {
73
+ if (err) {
74
+ resolve(null);
75
+ return;
76
+ }
77
+ const branch = stdout.trim();
78
+ resolve(branch || null);
79
+ });
80
+ });
81
+ }
82
+ mapsEqual(other) {
83
+ if (other.size !== this.branches.size)
84
+ return false;
85
+ for (const [key, val] of other) {
86
+ if (this.branches.get(key) !== val)
87
+ return false;
88
+ }
89
+ return true;
90
+ }
91
+ notify() {
92
+ const result = {};
93
+ for (const [id, branch] of this.branches) {
94
+ result[id] = branch;
95
+ }
96
+ this.onChange(result);
97
+ }
98
+ }
@@ -228,6 +228,31 @@ async function startServer(devMode, port) {
228
228
  const killed = ptyManager.killProject(req.params.id);
229
229
  res.json({ killed: killed.length });
230
230
  });
231
+ function getStaleProjects() {
232
+ const stale = [];
233
+ for (const project of projectStore.list()) {
234
+ try {
235
+ const stat = fs.statSync(project.cwd);
236
+ if (!stat.isDirectory())
237
+ throw new Error('not a directory');
238
+ }
239
+ catch {
240
+ stale.push({ id: project.id, name: project.name, cwd: project.cwd });
241
+ }
242
+ }
243
+ return stale;
244
+ }
245
+ app.get('/api/cleanup-projects', (_req, res) => {
246
+ res.json({ stale: getStaleProjects() });
247
+ });
248
+ app.post('/api/cleanup-projects', (_req, res) => {
249
+ const stale = getStaleProjects();
250
+ for (const project of stale) {
251
+ ptyManager.killProject(project.id);
252
+ projectStore.remove(project.id);
253
+ }
254
+ res.json({ removed: stale.map((p) => p.name) });
255
+ });
231
256
  app.post('/api/validate-path', (req, res) => {
232
257
  const { path: rawPath } = req.body;
233
258
  if (!rawPath) {
@@ -243,71 +268,6 @@ async function startServer(devMode, port) {
243
268
  res.json({ valid: false });
244
269
  }
245
270
  });
246
- // Active editor detection — single AppleScript gets frontmost app + window title
247
- const editorPatterns = ['cursor', 'code', 'vscode', 'visual studio code', 'zed', 'windsurf', 'electron'];
248
- let editorCache = { projectName: null };
249
- const editorScript = `
250
- tell application "System Events"
251
- set frontApp to name of first application process whose frontmost is true
252
- set winTitle to ""
253
- tell process frontApp
254
- if exists front window then
255
- set winTitle to name of front window
256
- end if
257
- end tell
258
- return frontApp & linefeed & winTitle
259
- end tell
260
- `;
261
- function pollActiveEditor() {
262
- if (process.platform !== 'darwin')
263
- return;
264
- execFile('osascript', ['-e', editorScript], { timeout: 2000 }, (err, stdout, stderr) => {
265
- if (err) {
266
- const needsAccess = stderr?.includes('not allowed assistive access') || stderr?.includes('1719');
267
- editorCache = { projectName: null, needsAccessibility: needsAccess || undefined };
268
- return;
269
- }
270
- const lines = stdout.trim().split('\n');
271
- const appName = (lines[0] || '').trim();
272
- const title = (lines[1] || '').trim();
273
- const isEditor = editorPatterns.some((pat) => appName.toLowerCase().includes(pat));
274
- if (!isEditor || !title) {
275
- editorCache = { projectName: null };
276
- return;
277
- }
278
- let projectName = null;
279
- // Try to extract a path from the title (e.g. "~/Documents/source/foo - branch")
280
- const pathMatch = title.match(/^(~?\/[^\s]+)/);
281
- if (pathMatch) {
282
- const segments = pathMatch[1].replace(/\/$/, '').split('/');
283
- projectName = segments[segments.length - 1] || null;
284
- }
285
- // Fallback: default title format "file — project — Editor" or "project — Editor"
286
- if (!projectName) {
287
- const parts = title.split(' \u2014 ');
288
- if (parts.length >= 3) {
289
- projectName = parts[parts.length - 2];
290
- }
291
- else if (parts.length === 2) {
292
- projectName = parts[0];
293
- }
294
- else {
295
- projectName = title;
296
- }
297
- }
298
- const prev = editorCache.projectName;
299
- editorCache = { projectName };
300
- if (projectName && projectName !== prev) {
301
- wsHandler.send({ type: 'editor:active', projectName });
302
- }
303
- });
304
- }
305
- // Poll every 500ms — single osascript call is fast
306
- pollActiveEditor();
307
- setInterval(pollActiveEditor, 500);
308
- app.get('/api/active-editor', (_req, res) => {
309
- res.json(editorCache);
310
- });
311
271
  // Resolve a dropped file's full path using OS file index (Spotlight on macOS)
312
272
  app.post('/api/resolve-path', (req, res) => {
313
273
  const { name, size, lastModified } = req.body;
@@ -390,6 +350,9 @@ async function startServer(devMode, port) {
390
350
  const server = http.createServer(app);
391
351
  // WebSocket handler
392
352
  const wsHandler = new WsHandler(server, ptyManager, projectStore, { onIdle: () => shutdown() });
353
+ app.get('/api/active-editor', (_req, res) => {
354
+ res.json(wsHandler.getEditorState());
355
+ });
393
356
  // IPC listener
394
357
  const ipcServer = startIpcListener(socketPath(), ptyManager, projectStore, wsHandler);
395
358
  server.on('error', (err) => {