@bakapiano/ccsm 0.9.0 → 0.10.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.
Files changed (69) hide show
  1. package/CLAUDE.md +222 -195
  2. package/README.md +77 -79
  3. package/lib/cliSessionWatcher.js +249 -0
  4. package/lib/config.js +101 -24
  5. package/lib/folders.js +96 -0
  6. package/lib/localCliSessions.js +177 -0
  7. package/lib/persistedSessions.js +134 -0
  8. package/lib/webTerminal.js +31 -18
  9. package/lib/workspace.js +26 -4
  10. package/package.json +1 -1
  11. package/public/assets/claude-color.svg +1 -0
  12. package/public/assets/codex-color.svg +1 -0
  13. package/public/assets/copilot-color.svg +1 -0
  14. package/public/css/base.css +22 -5
  15. package/public/css/cards.css +37 -3
  16. package/public/css/feedback.css +127 -43
  17. package/public/css/forms.css +97 -25
  18. package/public/css/layout.css +74 -26
  19. package/public/css/modal.css +40 -26
  20. package/public/css/responsive.css +2 -2
  21. package/public/css/sidebar.css +424 -25
  22. package/public/css/terminals.css +138 -0
  23. package/public/css/tokens.css +28 -12
  24. package/public/css/wco.css +38 -39
  25. package/public/css/widgets.css +1177 -6
  26. package/public/index.html +35 -2
  27. package/public/js/api.js +194 -37
  28. package/public/js/components/AdoptModal.js +171 -0
  29. package/public/js/components/App.js +1 -11
  30. package/public/js/components/DirectoryPicker.js +203 -0
  31. package/public/js/components/EntityFormModal.js +105 -0
  32. package/public/js/components/Modal.js +51 -0
  33. package/public/js/components/OfflineBanner.js +29 -23
  34. package/public/js/components/PageTitleBar.js +13 -0
  35. package/public/js/components/Picker.js +179 -0
  36. package/public/js/components/Popover.js +55 -0
  37. package/public/js/components/Sidebar.js +219 -32
  38. package/public/js/components/TerminalView.js +27 -3
  39. package/public/js/components/useDragSort.js +67 -0
  40. package/public/js/dialog.js +10 -2
  41. package/public/js/icons.js +66 -3
  42. package/public/js/main.js +54 -3
  43. package/public/js/pages/AboutPage.js +80 -0
  44. package/public/js/pages/ConfigurePage.js +429 -207
  45. package/public/js/pages/LaunchPage.js +326 -86
  46. package/public/js/pages/SessionsPage.js +91 -41
  47. package/public/js/state.js +102 -73
  48. package/public/manifest.webmanifest +2 -2
  49. package/scripts/install.js +7 -2
  50. package/server.js +755 -441
  51. package/lib/favorites.js +0 -51
  52. package/lib/focus.js +0 -369
  53. package/lib/labels.js +0 -29
  54. package/lib/launcher.js +0 -219
  55. package/lib/sessions.js +0 -272
  56. package/lib/snapshot.js +0 -141
  57. package/public/js/actions.js +0 -107
  58. package/public/js/components/Fab.js +0 -11
  59. package/public/js/components/FavoritesTable.js +0 -81
  60. package/public/js/components/Footer.js +0 -12
  61. package/public/js/components/NewSessionModal.js +0 -153
  62. package/public/js/components/PageHead.js +0 -33
  63. package/public/js/components/Pagination.js +0 -27
  64. package/public/js/components/RecentTable.js +0 -68
  65. package/public/js/components/SessionsTable.js +0 -71
  66. package/public/js/components/SnapshotPanel.js +0 -77
  67. package/public/js/components/TitleCell.js +0 -40
  68. package/public/js/components/WorkspacesGrid.js +0 -41
  69. package/public/js/pages/TerminalsPage.js +0 -74
package/lib/favorites.js DELETED
@@ -1,51 +0,0 @@
1
- 'use strict';
2
-
3
- // User-pinned ("favorited") sessions, keyed by sessionId at
4
- // $DATA_DIR/favorites.json. Each entry captures enough metadata
5
- // (cwd, title, gitBranch) to render the row even after the session's
6
- // jsonl is gone — entries are best-effort archival.
7
-
8
- const { DATA_DIR } = require('./config');
9
- const { createKeyedJsonStore } = require('./jsonStore');
10
-
11
- const store = createKeyedJsonStore({
12
- dataDir: DATA_DIR,
13
- filename: 'favorites.json',
14
- });
15
-
16
- async function addFavorite(sessionId, info = {}) {
17
- if (!sessionId) throw new Error('addFavorite: sessionId required');
18
- const map = await store.load();
19
- const existing = map[sessionId];
20
- const next = existing
21
- ? { ...existing, ...info, sessionId }
22
- : {
23
- sessionId,
24
- cwd: info.cwd || null,
25
- title: info.title || null,
26
- gitBranch: info.gitBranch || null,
27
- label: info.label || null,
28
- addedAt: Date.now(),
29
- };
30
- return store.set(sessionId, next);
31
- }
32
-
33
- async function hasFavorite(sessionId) {
34
- const map = await store.load();
35
- return sessionId in map;
36
- }
37
-
38
- async function listFavorites() {
39
- const list = await store.list();
40
- return list.sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
41
- }
42
-
43
- module.exports = {
44
- loadFavorites: store.load,
45
- saveFavorites: store.save,
46
- listFavorites,
47
- addFavorite,
48
- removeFavorite: store.remove,
49
- hasFavorite,
50
- FAVORITES_PATH: store.filePath,
51
- };
package/lib/focus.js DELETED
@@ -1,369 +0,0 @@
1
- 'use strict';
2
-
3
- // Focus helpers — find or raise a wt (or other terminal) window via Win32
4
- // APIs called through PowerShell + Add-Type.
5
- //
6
- // Two distinct use cases:
7
- // 1. focusByPid(pid) — for the "focus" button in the UI: given
8
- // a live claude.exe PID, walk parents to
9
- // find the wt window hosting it.
10
- // 2. snapshotWindowsOf(name) +
11
- // focusNewlyOpenedHwnd(...) — for auto-focus after launch: snapshot the
12
- // set of visible top-level windows owned by
13
- // processes named e.g. "WindowsTerminal.exe"
14
- // BEFORE launch, then poll for new HWNDs
15
- // and focus the diff. HWND-based because
16
- // modern wt is multi-window single-process —
17
- // PID-based diff would always return empty.
18
-
19
- const { spawn } = require('node:child_process');
20
-
21
- function sleep(ms) {
22
- return new Promise((r) => setTimeout(r, ms));
23
- }
24
-
25
- // One PowerShell helper handles both: list-windows-of and focus-hwnd modes.
26
- // Mode is passed via env var so we don't fight quoting.
27
- const FOCUS_HELPER_PS = String.raw`
28
- $ErrorActionPreference = 'Stop'
29
-
30
- Add-Type @'
31
- using System;
32
- using System.Collections.Generic;
33
- using System.Runtime.InteropServices;
34
- using System.Text;
35
- using System.Threading;
36
- public class CcsmWin {
37
- [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
38
- [DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr h, bool t);
39
- [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr h, int n);
40
- [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
41
- [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
42
- [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool x);
43
- [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
44
- [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
45
- [DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra);
46
- [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);
47
- [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr p);
48
- [DllImport("user32.dll", CharSet=CharSet.Auto)] public static extern int GetWindowText(IntPtr h, StringBuilder s, int max);
49
- [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr h);
50
- [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT rect);
51
- [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);
52
- [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT lpPoint);
53
- [DllImport("user32.dll")] public static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
54
- [DllImport("user32.dll")] public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO mi);
55
- [DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr h, int x, int y, int w, int hgt, bool repaint);
56
- public delegate bool EnumWindowsProc(IntPtr h, IntPtr p);
57
-
58
- [StructLayout(LayoutKind.Sequential)]
59
- public struct RECT { public int Left, Top, Right, Bottom; }
60
- [StructLayout(LayoutKind.Sequential)]
61
- public struct POINT { public int X, Y; }
62
- [StructLayout(LayoutKind.Sequential)]
63
- public struct MONITORINFO {
64
- public uint cbSize;
65
- public RECT rcMonitor;
66
- public RECT rcWork;
67
- public uint dwFlags;
68
- }
69
- const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
70
-
71
- public static List<object> EnumVisibleTopLevel() {
72
- var results = new List<object>();
73
- EnumWindows((h, l) => {
74
- if (!IsWindowVisible(h)) return true;
75
- int len = GetWindowTextLength(h);
76
- if (len == 0) return true;
77
- uint pid = 0;
78
- GetWindowThreadProcessId(h, out pid);
79
- var sb = new StringBuilder(len + 1);
80
- GetWindowText(h, sb, sb.Capacity);
81
- results.Add(new { hwnd = h.ToInt64(), pid = pid, title = sb.ToString() });
82
- return true;
83
- }, IntPtr.Zero);
84
- return results;
85
- }
86
-
87
- // SWP flags
88
- const uint SWP_NOSIZE = 0x0001;
89
- const uint SWP_NOZORDER = 0x0004;
90
- const uint SWP_NOACTIVATE = 0x0010;
91
- const uint SWP_ASYNCWINDOWPOS = 0x4000;
92
- const uint SWP_FLAGS = SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS;
93
-
94
- // Horizontal shake — small left/right offsets so the user's eye snaps
95
- // to the activated window. ~220ms total.
96
- public static void Jiggle(IntPtr h) {
97
- RECT rc;
98
- if (!GetWindowRect(h, out rc)) return;
99
- int x = rc.Left;
100
- int y = rc.Top;
101
- int[] dx = new int[] { 8, -8, 6, -6, 0 };
102
- foreach (int d in dx) {
103
- SetWindowPos(h, IntPtr.Zero, x + d, y, 0, 0, SWP_FLAGS);
104
- Thread.Sleep(38);
105
- }
106
- }
107
-
108
- // Move the window to the center of whichever monitor the cursor is on.
109
- // Preserves window size. Returns true if it moved.
110
- public static bool MoveToCursorScreenCenter(IntPtr h) {
111
- RECT wr;
112
- if (!GetWindowRect(h, out wr)) return false;
113
- int ww = wr.Right - wr.Left;
114
- int wh = wr.Bottom - wr.Top;
115
-
116
- POINT cursor;
117
- if (!GetCursorPos(out cursor)) return false;
118
- IntPtr hMon = MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST);
119
- if (hMon == IntPtr.Zero) return false;
120
-
121
- MONITORINFO mi = new MONITORINFO();
122
- mi.cbSize = (uint)Marshal.SizeOf(typeof(MONITORINFO));
123
- if (!GetMonitorInfo(hMon, ref mi)) return false;
124
-
125
- // Use the work area (excludes taskbar) so a centered window isn't
126
- // pushed under the taskbar.
127
- int sw = mi.rcWork.Right - mi.rcWork.Left;
128
- int sh = mi.rcWork.Bottom - mi.rcWork.Top;
129
- int nx = mi.rcWork.Left + (sw - ww) / 2;
130
- int ny = mi.rcWork.Top + (sh - wh) / 2;
131
- return MoveWindow(h, nx, ny, ww, wh, true);
132
- }
133
-
134
- public static bool Activate(IntPtr h, bool moveToCenter) {
135
- if (IsIconic(h)) ShowWindowAsync(h, 9);
136
- keybd_event(0x12, 0, 0, UIntPtr.Zero);
137
- keybd_event(0x12, 0, 0x0002, UIntPtr.Zero);
138
- uint ownerPid = 0;
139
- uint t = GetWindowThreadProcessId(h, out ownerPid);
140
- uint c = GetCurrentThreadId();
141
- bool attached = false;
142
- if (t != c) attached = AttachThreadInput(c, t, true);
143
- BringWindowToTop(h);
144
- bool ok = SetForegroundWindow(h);
145
- SwitchToThisWindow(h, true);
146
- if (attached) AttachThreadInput(c, t, false);
147
- if (moveToCenter) MoveToCursorScreenCenter(h);
148
- Jiggle(h);
149
- return ok;
150
- }
151
- }
152
- '@ | Out-Null
153
-
154
- $mode = $env:CCSM_FOCUS_MODE
155
- $arg = $env:CCSM_FOCUS_ARG
156
- $moveToCenter = $env:CCSM_FOCUS_CENTER -eq '1'
157
-
158
- if ($mode -eq 'list') {
159
- $procName = $arg -replace '\.exe$',''
160
- $pidSet = @{}
161
- foreach ($p in (Get-Process -Name $procName -ErrorAction SilentlyContinue)) {
162
- $pidSet[[uint32]$p.Id] = $true
163
- }
164
- $all = [CcsmWin]::EnumVisibleTopLevel()
165
- $items = @()
166
- foreach ($w in $all) {
167
- if ($pidSet.ContainsKey([uint32]$w.pid)) {
168
- $items += (ConvertTo-Json @{
169
- hwnd = [int64]$w.hwnd
170
- pid = [int64]$w.pid
171
- title = [string]$w.title
172
- } -Compress)
173
- }
174
- }
175
- Write-Output ('[' + ($items -join ',') + ']')
176
- exit 0
177
- }
178
-
179
- if ($mode -eq 'focus-hwnd') {
180
- $hwndInt = [int64]$arg
181
- $hwnd = [IntPtr]::new($hwndInt)
182
- $ok = [CcsmWin]::Activate($hwnd, $moveToCenter)
183
- Write-Output (ConvertTo-Json @{ ok = $true; activated = $ok; hwnd = $hwndInt } -Compress)
184
- exit 0
185
- }
186
-
187
- if ($mode -eq 'focus-pid') {
188
- $current = [int]$arg
189
- $hwnd = [IntPtr]::Zero
190
- $found = $null
191
- $chain = @()
192
- for ($i = 0; $i -lt 12; $i++) {
193
- $p = $null
194
- try { $p = Get-Process -Id $current -ErrorAction Stop } catch { break }
195
- $chain += @{ pid = $p.Id; name = $p.ProcessName; hwnd = $p.MainWindowHandle.ToInt64() }
196
- if ($p.MainWindowHandle -ne [IntPtr]::Zero) { $hwnd = $p.MainWindowHandle; $found = $p; break }
197
- $parent = Get-CimInstance Win32_Process -Filter "ProcessId=$current" -ErrorAction SilentlyContinue | Select-Object -First 1
198
- if (-not $parent -or -not $parent.ParentProcessId) { break }
199
- $current = [int]$parent.ParentProcessId
200
- }
201
- if ($hwnd -eq [IntPtr]::Zero) {
202
- Write-Output (ConvertTo-Json @{ ok = $false; error = "no window handle found for pid $arg"; chain = $chain } -Compress -Depth 5)
203
- exit 1
204
- }
205
- $activated = [CcsmWin]::Activate($hwnd, $moveToCenter)
206
- Write-Output (ConvertTo-Json @{
207
- ok = $true; activated = $activated
208
- hwnd = $hwnd.ToInt64()
209
- windowPid = $found.Id
210
- windowProcess = $found.ProcessName
211
- windowTitle = $found.MainWindowTitle
212
- chain = $chain
213
- } -Compress -Depth 5)
214
- exit 0
215
- }
216
-
217
- Write-Output (ConvertTo-Json @{ ok = $false; error = "unknown CCSM_FOCUS_MODE: $mode" } -Compress)
218
- exit 1
219
- `;
220
-
221
- function runPsHelper(mode, arg, opts = {}) {
222
- return new Promise((resolve, reject) => {
223
- const encoded = Buffer.from(FOCUS_HELPER_PS, 'utf16le').toString('base64');
224
- const child = spawn(
225
- 'powershell.exe',
226
- ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
227
- {
228
- windowsHide: true,
229
- env: {
230
- ...process.env,
231
- CCSM_FOCUS_MODE: mode,
232
- CCSM_FOCUS_ARG: String(arg),
233
- CCSM_FOCUS_CENTER: opts.moveToCenter ? '1' : '0',
234
- },
235
- }
236
- );
237
- let out = '';
238
- let err = '';
239
- child.stdout.on('data', (d) => (out += d.toString()));
240
- child.stderr.on('data', (d) => (err += d.toString()));
241
- child.on('error', reject);
242
- child.on('close', (code) => {
243
- const last = out.trim().split(/\r?\n/).pop();
244
- try {
245
- const parsed = JSON.parse(last);
246
- if (Array.isArray(parsed)) {
247
- resolve({ exitCode: code, value: parsed, stderr: err.trim() || undefined });
248
- } else {
249
- resolve({ exitCode: code, ...parsed, stderr: err.trim() || undefined });
250
- }
251
- } catch (e) {
252
- reject(
253
- new Error(
254
- `focus helper (mode=${mode}) exit ${code}: ${err || out || '(no output)'}`
255
- )
256
- );
257
- }
258
- });
259
- });
260
- }
261
-
262
- // ---- public API ----
263
-
264
- async function focusByPid(pid, opts = {}) {
265
- const n = Number(pid);
266
- if (!Number.isInteger(n) || n <= 0) throw new Error(`focusByPid: invalid pid ${pid}`);
267
- return await runPsHelper('focus-pid', n, opts);
268
- }
269
-
270
- // Strip the leading wt status glyph + whitespace ("✳ ", "⠐ ", "⠠ " etc) so
271
- // the title compares cleanly. wt prefixes the tab title with a Braille-style
272
- // progress glyph or a sparkle/asterisk depending on activity.
273
- function cleanWtTitle(t) {
274
- return String(t || '').replace(/^[^\w一-鿿]+/, '').trim();
275
- }
276
-
277
- // Best-effort: focus the wt window that belongs to a specific session.
278
- // Modern wt is single-process multi-window, so walking from claude.exe PID
279
- // up to wt.exe + taking MainWindowHandle always returns the same canonical
280
- // window regardless of which tab the session is actually in. Instead we
281
- // list all wt windows and match by tab title against the session's AI
282
- // title / cwd basename.
283
- async function focusBySession({ pid, sessionId, title, cwd, moveToCenter = false }) {
284
- const procName = 'WindowsTerminal.exe';
285
- const cands = await listWindowsOf(procName);
286
- const opts = { moveToCenter };
287
-
288
- const cleanedTitle = title ? cleanWtTitle(title) : '';
289
- const cwdBase = cwd ? require('node:path').basename(cwd) : '';
290
-
291
- // 1. Exact match on cleaned ai-title
292
- if (cleanedTitle) {
293
- const exact = cands.filter((w) => cleanWtTitle(w.title) === cleanedTitle);
294
- if (exact.length === 1) {
295
- const r = await focusByHwnd(exact[0].hwnd, opts);
296
- return { ...r, matchedBy: 'title-exact', hwnd: exact[0].hwnd, windowTitle: exact[0].title };
297
- }
298
- }
299
-
300
- // 2. Title substring match (some shells append " - <cwd>" etc)
301
- if (cleanedTitle) {
302
- const subs = cands.filter((w) => w.title.includes(cleanedTitle));
303
- if (subs.length === 1) {
304
- const r = await focusByHwnd(subs[0].hwnd, opts);
305
- return { ...r, matchedBy: 'title-substring', hwnd: subs[0].hwnd, windowTitle: subs[0].title };
306
- }
307
- }
308
-
309
- // 3. Match by cwd basename (workspace name shows up in title for fresh launches)
310
- if (cwdBase) {
311
- const byCwd = cands.filter((w) => w.title.includes(cwdBase));
312
- if (byCwd.length === 1) {
313
- const r = await focusByHwnd(byCwd[0].hwnd, opts);
314
- return { ...r, matchedBy: 'cwd-basename', hwnd: byCwd[0].hwnd, windowTitle: byCwd[0].title };
315
- }
316
- }
317
-
318
- // 4. Fall back to PID parent-chain walk (returns the wt process's
319
- // canonical MainWindowHandle — may be the wrong window when wt is
320
- // multi-window single-process, but better than nothing).
321
- if (pid) {
322
- const r = await focusByPid(pid, opts);
323
- return { ...r, matchedBy: 'pid-fallback', ambiguous: true };
324
- }
325
- return { ok: false, error: 'no match by title/cwd and no pid given', matchedBy: 'none' };
326
- }
327
-
328
- async function focusByHwnd(hwnd, opts = {}) {
329
- return await runPsHelper('focus-hwnd', hwnd, opts);
330
- }
331
-
332
- async function listWindowsOf(processName) {
333
- const r = await runPsHelper('list', processName);
334
- return Array.isArray(r.value) ? r.value : [];
335
- }
336
-
337
- async function snapshotWindowsOf(processName) {
338
- const list = await listWindowsOf(processName);
339
- return new Set(list.map((w) => Number(w.hwnd)));
340
- }
341
-
342
- async function focusNewlyOpenedHwnd(beforeHwnds, processName, opts = {}) {
343
- const { timeoutMs = 8000, intervalMs = 300 } = opts;
344
- const deadline = Date.now() + timeoutMs;
345
- await sleep(intervalMs);
346
- while (Date.now() < deadline) {
347
- const after = await listWindowsOf(processName);
348
- const fresh = after.filter((w) => !beforeHwnds.has(Number(w.hwnd)));
349
- if (fresh.length > 0) {
350
- const target = fresh[fresh.length - 1];
351
- const r = await focusByHwnd(target.hwnd);
352
- return { ...r, hwnd: target.hwnd, title: target.title, candidates: fresh };
353
- }
354
- await sleep(intervalMs);
355
- }
356
- return {
357
- ok: false,
358
- error: `no new ${processName} window appeared within ${timeoutMs}ms`,
359
- };
360
- }
361
-
362
- module.exports = {
363
- focusByPid,
364
- focusBySession,
365
- focusByHwnd,
366
- listWindowsOf,
367
- snapshotWindowsOf,
368
- focusNewlyOpenedHwnd,
369
- };
package/lib/labels.js DELETED
@@ -1,29 +0,0 @@
1
- 'use strict';
2
-
3
- // User-defined display titles for sessions, keyed by sessionId at
4
- // $DATA_DIR/labels.json. Frontend overlays the label on top of the
5
- // AI-generated title when rendering live / recent / favorites.
6
-
7
- const path = require('node:path');
8
- const { DATA_DIR } = require('./config');
9
- const { createKeyedJsonStore } = require('./jsonStore');
10
-
11
- const MAX_LEN = 200;
12
-
13
- const store = createKeyedJsonStore({
14
- dataDir: DATA_DIR,
15
- filename: 'labels.json',
16
- // Empty / null label triggers a remove via the factory contract.
17
- transformValue: (v) => {
18
- const trimmed = String(v || '').trim().slice(0, MAX_LEN);
19
- return trimmed || null;
20
- },
21
- });
22
-
23
- module.exports = {
24
- loadLabels: store.load,
25
- saveLabels: store.save,
26
- setLabel: store.set,
27
- removeLabel: store.remove,
28
- LABELS_PATH: store.filePath,
29
- };
package/lib/launcher.js DELETED
@@ -1,219 +0,0 @@
1
- 'use strict';
2
-
3
- const { spawn, exec } = require('node:child_process');
4
- const path = require('node:path');
5
- const fs = require('node:fs');
6
-
7
- // Terminal kinds we know how to open a new window for. Each entry has:
8
- // processName — what shows up in tasklist, used by focus.js to find the
9
- // newly-created window via HWND diff.
10
- // spawn(opts) — returns { spawned: ChildProcess, args: string[] }.
11
- //
12
- // All variants take { cwd, command, args, title, commandShell } and open a
13
- // new on-screen window with `command args...` running in `cwd`. `commandShell`
14
- // only matters for the wt kind — see comment there.
15
- const TERMINAL_KINDS = {
16
- wt: {
17
- processName: 'WindowsTerminal.exe',
18
- spawn({ cwd, command, args, title, commandShell }) {
19
- // `-w new` forces a new wt window. Without it, recent wt versions
20
- // honor the user's "windowingBehavior" setting and may fold the
21
- // invocation into an existing window as a tab — which breaks the
22
- // "one window per session" promise and the auto-focus HWND diff.
23
- const wtArgs = ['-w', 'new'];
24
- if (title) wtArgs.push('--title', title);
25
- wtArgs.push('-d', cwd);
26
- if (command) {
27
- // wt by default runs the command via CreateProcess (no shell), so a
28
- // PowerShell alias / function / profile-defined name like "ccp" can't
29
- // be found. Wrapping in pwsh/powershell loads $PROFILE and resolves
30
- // those names. commandShell="none" reverts to direct invocation for
31
- // anyone who wants raw exe semantics.
32
- //
33
- // We use -EncodedCommand (base64 UTF-16LE) instead of -Command because
34
- // wt's CLI parser treats `;` as a sub-command separator at any nesting
35
- // depth — a `;` inside our -Command string would make wt try to launch
36
- // whatever follows as a brand-new wt sub-command. Base64 has no `;`.
37
- if (commandShell === 'pwsh' || commandShell === 'powershell') {
38
- const shellExe = commandShell === 'powershell' ? 'powershell.exe' : 'pwsh.exe';
39
- wtArgs.push(
40
- shellExe,
41
- '-NoExit', '-NoLogo',
42
- '-EncodedCommand', buildPwshEncodedCommand({ cwd, command, args })
43
- );
44
- } else {
45
- wtArgs.push(command, ...args);
46
- }
47
- }
48
- const spawned = spawn('wt.exe', wtArgs, {
49
- detached: true,
50
- stdio: 'ignore',
51
- windowsHide: false,
52
- });
53
- spawned.unref();
54
- return { spawned, args: wtArgs };
55
- },
56
- },
57
- powershell: {
58
- processName: 'powershell.exe',
59
- spawn(opts) {
60
- return spawnViaCmdStart('powershell.exe', opts);
61
- },
62
- },
63
- pwsh: {
64
- processName: 'pwsh.exe',
65
- spawn(opts) {
66
- return spawnViaCmdStart('pwsh.exe', opts);
67
- },
68
- },
69
- cmd: {
70
- processName: 'cmd.exe',
71
- spawn({ cwd, command, args, title }) {
72
- // cmd /K runs a command and stays open. We use `start` to create a new
73
- // window. The empty "" is the new window's title slot.
74
- const inner = command
75
- ? [command, ...args].map(quoteForCmd).join(' ')
76
- : '';
77
- const cmdLine = inner ? `/K ${inner}` : '/K';
78
- const spawned = spawn(
79
- 'cmd.exe',
80
- ['/c', 'start', title || '', '/D', cwd, 'cmd.exe', cmdLine],
81
- { detached: true, stdio: 'ignore', windowsHide: false }
82
- );
83
- spawned.unref();
84
- return { spawned, args: ['/c', 'start', title || '', '/D', cwd, 'cmd.exe', cmdLine] };
85
- },
86
- },
87
- };
88
-
89
- function quoteForCmd(s) {
90
- if (s == null) return '';
91
- const str = String(s);
92
- if (/[\s"&|<>^]/.test(str)) return '"' + str.replace(/"/g, '\\"') + '"';
93
- return str;
94
- }
95
-
96
- function quoteForPwsh(s) {
97
- return "'" + String(s).replace(/'/g, "''") + "'";
98
- }
99
-
100
- // Build a PowerShell command-line string that cd's into cwd then invokes
101
- // command with args via `&` (works for aliases, functions, scripts, exes).
102
- function buildPwshScript({ cwd, command, args }) {
103
- const pieces = [`Set-Location -LiteralPath ${quoteForPwsh(cwd)}`];
104
- if (command) {
105
- const argTail = (args || []).map(quoteForPwsh).join(' ');
106
- pieces.push(`& ${quoteForPwsh(command)} ${argTail}`.trim());
107
- }
108
- return pieces.join('; ');
109
- }
110
-
111
- // PowerShell -EncodedCommand expects UTF-16LE base64. We pass scripts this
112
- // way so the wt CLI parser doesn't munge ';' (which wt uses as a sub-command
113
- // separator at any nesting depth) or other shell metacharacters.
114
- function buildPwshEncodedCommand(opts) {
115
- return Buffer.from(buildPwshScript(opts), 'utf16le').toString('base64');
116
- }
117
-
118
- // Helper for the powershell/pwsh kinds: open a new window via `cmd /c start`
119
- // running powershell/pwsh that cd's to cwd and runs the command.
120
- function spawnViaCmdStart(psExe, { cwd, command, args, title }) {
121
- const psScript = buildPwshScript({ cwd, command, args });
122
- const startArgs = [
123
- '/c', 'start',
124
- title || '',
125
- '/D', cwd,
126
- psExe,
127
- '-NoExit', '-NoLogo',
128
- '-Command', psScript,
129
- ];
130
- const spawned = spawn('cmd.exe', startArgs, {
131
- detached: true,
132
- stdio: 'ignore',
133
- windowsHide: false,
134
- });
135
- spawned.unref();
136
- return { spawned, args: startArgs };
137
- }
138
-
139
- function launchInTerminal({
140
- cwd,
141
- command = null,
142
- args = [],
143
- title = null,
144
- terminal = 'wt',
145
- commandShell = 'pwsh',
146
- }) {
147
- if (!cwd) throw new Error('launchInTerminal: cwd required');
148
- const kind = TERMINAL_KINDS[terminal];
149
- if (!kind) throw new Error(`launchInTerminal: unknown terminal "${terminal}"`);
150
- const resolved = path.resolve(cwd);
151
- if (!fs.existsSync(resolved)) {
152
- throw new Error(`launchInTerminal: cwd does not exist: ${resolved}`);
153
- }
154
- const { spawned, args: launchedArgs } = kind.spawn({
155
- cwd: resolved,
156
- command,
157
- args,
158
- title,
159
- commandShell,
160
- });
161
- return {
162
- pid: spawned.pid,
163
- cwd: resolved,
164
- terminal,
165
- commandShell,
166
- processName: kind.processName,
167
- args: launchedArgs,
168
- };
169
- }
170
-
171
- // Convenience wrappers — claudeCommand defaults to 'claude' but should be
172
- // supplied by the caller from config so the user's preference applies.
173
- function launchResume({ cwd, sessionId, title = null, terminal = 'wt', claudeCommand = 'claude', commandShell = 'pwsh' }) {
174
- return launchInTerminal({
175
- cwd,
176
- command: claudeCommand,
177
- args: ['--resume', sessionId],
178
- title: title || `resume ${sessionId.slice(0, 8)}`,
179
- terminal,
180
- commandShell,
181
- });
182
- }
183
-
184
- function launchNewClaude({
185
- cwd,
186
- title = null,
187
- extraArgs = [],
188
- terminal = 'wt',
189
- claudeCommand = 'claude',
190
- commandShell = 'pwsh',
191
- }) {
192
- return launchInTerminal({
193
- cwd,
194
- command: claudeCommand,
195
- args: extraArgs,
196
- title: title || path.basename(cwd),
197
- terminal,
198
- commandShell,
199
- });
200
- }
201
-
202
- function listTerminalKinds() {
203
- return Object.keys(TERMINAL_KINDS).map((name) => ({
204
- name,
205
- processName: TERMINAL_KINDS[name].processName,
206
- }));
207
- }
208
-
209
- function processNameFor(terminal) {
210
- return TERMINAL_KINDS[terminal] ? TERMINAL_KINDS[terminal].processName : null;
211
- }
212
-
213
- module.exports = {
214
- launchInTerminal,
215
- launchResume,
216
- launchNewClaude,
217
- listTerminalKinds,
218
- processNameFor,
219
- };