pomitu 1.4.0 → 1.6.0

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
@@ -98,7 +98,7 @@ If you want to contribute to Pomitu or run it in development mode:
98
98
  Dev installation test:
99
99
 
100
100
  ```sh
101
- npm run build && npm pack && npm install -g pomitu-1.4.0.tgz && rm pomitu-1.4.0.tgz
101
+ npm run build && npm pack && npm install -g pomitu-1.6.0.tgz && rm pomitu-1.6.0.tgz
102
102
  ```
103
103
 
104
104
  ### Linting
@@ -47,6 +47,9 @@ export const start = new Command('start')
47
47
  });
48
48
  }
49
49
  }
50
+ if (runInteractive && anyTuiActive) {
51
+ console.log('A TUI session is already running in another terminal — not opening a new one');
52
+ }
50
53
  if (runInteractive && !anyTuiActive) {
51
54
  render(React.createElement(ProcessTUI, {
52
55
  configPath: name,
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Drop-in replacement for ink-select-input's SelectInput that preserves scroll
3
+ * position when items change, instead of resetting to the top.
4
+ */
5
+ import { isDeepStrictEqual } from 'node:util';
6
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
7
+ import { Box, useInput, useStdin } from 'ink';
8
+ import { Indicator, Item as ItemComponent } from 'ink-select-input';
9
+ function rotateArray(arr, k) {
10
+ if (arr.length === 0)
11
+ return arr;
12
+ const n = arr.length;
13
+ const steps = ((k % n) + n) % n;
14
+ if (steps === 0)
15
+ return [...arr];
16
+ return [...arr.slice(-steps), ...arr.slice(0, -steps)];
17
+ }
18
+ export function PersistentSelectInput({ items = [], isFocused = true, initialIndex = 0, indicatorComponent: IndicatorComp = Indicator, itemComponent: ItemComp = ItemComponent, limit: customLimit, onSelect, onHighlight, headingPredicate, }) {
19
+ const hasLimit = typeof customLimit === 'number' && items.length > customLimit;
20
+ const limit = hasLimit ? Math.min(customLimit, items.length) : items.length;
21
+ const lastIndex = limit - 1;
22
+ const [rotateIndex, setRotateIndex] = useState(initialIndex > lastIndex ? lastIndex - initialIndex : 0);
23
+ const [selectedIndex, setSelectedIndex] = useState(initialIndex ? (initialIndex > lastIndex ? lastIndex : initialIndex) : 0);
24
+ const previousItems = useRef(items);
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ const { internal_eventEmitter } = useStdin();
27
+ useEffect(() => {
28
+ if (!isFocused || items.length === 0)
29
+ return;
30
+ const handleHomeEnd = (data) => {
31
+ const str = data.toString();
32
+ const isHome = str === '\x1b[H' || str === '\x1bOH' || str === '\x1b[1~' || str === '\x1b[7~';
33
+ const isEnd = str === '\x1b[F' || str === '\x1bOF' || str === '\x1b[4~' || str === '\x1b[8~';
34
+ if (isHome) {
35
+ setSelectedIndex(0);
36
+ setRotateIndex(0);
37
+ if (typeof onHighlight === 'function' && items[0])
38
+ onHighlight(items[0]);
39
+ }
40
+ if (isEnd) {
41
+ if (hasLimit) {
42
+ const newRotateIndex = -(items.length - limit);
43
+ setRotateIndex(newRotateIndex);
44
+ setSelectedIndex(limit - 1);
45
+ const sliced = rotateArray(items, newRotateIndex).slice(0, limit);
46
+ if (typeof onHighlight === 'function' && sliced[limit - 1])
47
+ onHighlight(sliced[limit - 1]);
48
+ }
49
+ else {
50
+ const lastIdx = items.length - 1;
51
+ setSelectedIndex(lastIdx);
52
+ if (typeof onHighlight === 'function' && items[lastIdx])
53
+ onHighlight(items[lastIdx]);
54
+ }
55
+ }
56
+ };
57
+ internal_eventEmitter?.on('input', handleHomeEnd);
58
+ return () => {
59
+ internal_eventEmitter?.removeListener('input', handleHomeEnd);
60
+ };
61
+ }, [isFocused, items, hasLimit, limit, onHighlight, internal_eventEmitter]);
62
+ useEffect(() => {
63
+ if (!isDeepStrictEqual(previousItems.current.map(item => item.value), items.map(item => item.value))) {
64
+ const newHasLimit = typeof customLimit === 'number' && items.length > customLimit;
65
+ if (newHasLimit) {
66
+ // Clamp scroll and cursor to new valid bounds
67
+ const maxScroll = items.length - customLimit;
68
+ setRotateIndex(prev => Math.max(-maxScroll, Math.min(0, prev)));
69
+ setSelectedIndex(prev => Math.min(prev, customLimit - 1));
70
+ }
71
+ else {
72
+ // No scrolling needed anymore
73
+ setRotateIndex(0);
74
+ setSelectedIndex(prev => Math.min(prev, Math.max(0, items.length - 1)));
75
+ }
76
+ }
77
+ previousItems.current = items;
78
+ }, [items, customLimit]);
79
+ useInput(useCallback((input, key) => {
80
+ if (input === 'k' || key.upArrow) {
81
+ const atVisualTop = selectedIndex === 0;
82
+ if (hasLimit) {
83
+ if (atVisualTop) {
84
+ if (rotateIndex < 0) {
85
+ // Scroll window up
86
+ const nextRotateIndex = rotateIndex + 1;
87
+ setRotateIndex(nextRotateIndex);
88
+ const sliced = rotateArray(items, nextRotateIndex).slice(0, limit);
89
+ if (typeof onHighlight === 'function' && sliced[0])
90
+ onHighlight(sliced[0]);
91
+ }
92
+ // else: already at absolute top, do nothing
93
+ }
94
+ else {
95
+ const nextSelectedIndex = selectedIndex - 1;
96
+ setSelectedIndex(nextSelectedIndex);
97
+ const sliced = rotateArray(items, rotateIndex).slice(0, limit);
98
+ if (typeof onHighlight === 'function' && sliced[nextSelectedIndex])
99
+ onHighlight(sliced[nextSelectedIndex]);
100
+ }
101
+ }
102
+ else {
103
+ // No limit: wrap around
104
+ const nextSelectedIndex = atVisualTop ? items.length - 1 : selectedIndex - 1;
105
+ setSelectedIndex(nextSelectedIndex);
106
+ if (typeof onHighlight === 'function' && items[nextSelectedIndex])
107
+ onHighlight(items[nextSelectedIndex]);
108
+ }
109
+ }
110
+ if (input === 'j' || key.downArrow) {
111
+ const atVisualBottom = selectedIndex === (hasLimit ? limit : items.length) - 1;
112
+ if (hasLimit) {
113
+ const atAbsBottom = rotateIndex <= -(items.length - limit);
114
+ if (atVisualBottom) {
115
+ if (!atAbsBottom) {
116
+ // Scroll window down
117
+ const nextRotateIndex = rotateIndex - 1;
118
+ setRotateIndex(nextRotateIndex);
119
+ const sliced = rotateArray(items, nextRotateIndex).slice(0, limit);
120
+ if (typeof onHighlight === 'function' && sliced[limit - 1])
121
+ onHighlight(sliced[limit - 1]);
122
+ }
123
+ // else: already at absolute bottom, do nothing
124
+ }
125
+ else {
126
+ const nextSelectedIndex = selectedIndex + 1;
127
+ setSelectedIndex(nextSelectedIndex);
128
+ const sliced = rotateArray(items, rotateIndex).slice(0, limit);
129
+ if (typeof onHighlight === 'function' && sliced[nextSelectedIndex])
130
+ onHighlight(sliced[nextSelectedIndex]);
131
+ }
132
+ }
133
+ else {
134
+ // No limit: wrap around
135
+ const nextSelectedIndex = atVisualBottom ? 0 : selectedIndex + 1;
136
+ setSelectedIndex(nextSelectedIndex);
137
+ if (typeof onHighlight === 'function' && items[nextSelectedIndex])
138
+ onHighlight(items[nextSelectedIndex]);
139
+ }
140
+ }
141
+ if (key.pageUp || key.pageDown) {
142
+ const absIndex = hasLimit ? -rotateIndex + selectedIndex : selectedIndex;
143
+ let targetAbs = -1;
144
+ if (headingPredicate) {
145
+ if (key.pageUp) {
146
+ for (let i = absIndex - 1; i >= 0; i--) {
147
+ if (items[i] && headingPredicate(items[i])) {
148
+ targetAbs = i;
149
+ break;
150
+ }
151
+ }
152
+ }
153
+ else {
154
+ for (let i = absIndex + 1; i < items.length; i++) {
155
+ if (items[i] && headingPredicate(items[i])) {
156
+ targetAbs = i;
157
+ break;
158
+ }
159
+ }
160
+ }
161
+ }
162
+ else {
163
+ targetAbs = key.pageUp
164
+ ? Math.max(absIndex - limit, 0)
165
+ : Math.min(absIndex + limit, items.length - 1);
166
+ }
167
+ if (targetAbs < 0)
168
+ return;
169
+ if (hasLimit) {
170
+ const windowStart = -rotateIndex;
171
+ let newWindowStart;
172
+ if (targetAbs < windowStart) {
173
+ newWindowStart = targetAbs;
174
+ }
175
+ else if (targetAbs >= windowStart + limit) {
176
+ newWindowStart = Math.min(targetAbs, items.length - limit);
177
+ }
178
+ else {
179
+ newWindowStart = windowStart;
180
+ }
181
+ const newRotateIndex = -newWindowStart;
182
+ const newSelectedIndex = targetAbs - newWindowStart;
183
+ setRotateIndex(newRotateIndex);
184
+ setSelectedIndex(newSelectedIndex);
185
+ const sliced = rotateArray(items, newRotateIndex).slice(0, limit);
186
+ if (typeof onHighlight === 'function' && sliced[newSelectedIndex])
187
+ onHighlight(sliced[newSelectedIndex]);
188
+ }
189
+ else {
190
+ setSelectedIndex(targetAbs);
191
+ if (typeof onHighlight === 'function' && items[targetAbs])
192
+ onHighlight(items[targetAbs]);
193
+ }
194
+ }
195
+ if (/^[1-9]$/.test(input)) {
196
+ const targetIndex = Number.parseInt(input, 10) - 1;
197
+ const visibleItems = hasLimit
198
+ ? rotateArray(items, rotateIndex).slice(0, limit)
199
+ : items;
200
+ if (targetIndex >= 0 && targetIndex < visibleItems.length) {
201
+ const selectedItem = visibleItems[targetIndex];
202
+ if (selectedItem) {
203
+ onSelect?.(selectedItem);
204
+ }
205
+ }
206
+ }
207
+ if (key.return) {
208
+ const slicedItems = hasLimit
209
+ ? rotateArray(items, rotateIndex).slice(0, limit)
210
+ : items;
211
+ if (typeof onSelect === 'function') {
212
+ onSelect(slicedItems[selectedIndex]);
213
+ }
214
+ }
215
+ }, [hasLimit, limit, rotateIndex, selectedIndex, items, onSelect, onHighlight, headingPredicate]), { isActive: isFocused });
216
+ const slicedItems = hasLimit
217
+ ? rotateArray(items, rotateIndex).slice(0, limit)
218
+ : items;
219
+ return (React.createElement(Box, { flexDirection: "column" }, slicedItems.map((item, index) => {
220
+ const isSelected = index === selectedIndex;
221
+ return (React.createElement(Box, { key: item.key ?? String(item.value) },
222
+ React.createElement(IndicatorComp, { isSelected: isSelected }),
223
+ React.createElement(ItemComp, { isSelected: isSelected, label: item.label })));
224
+ })));
225
+ }
@@ -2,8 +2,8 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
2
2
  import readline from 'node:readline';
3
3
  import { existsSync } from 'node:fs';
4
4
  import open from 'open';
5
- import { Box, Text, useApp, useStdin } from 'ink';
6
- import SelectInput from 'ink-select-input';
5
+ import { Box, Text, useApp, useStdin, useInput } from 'ink';
6
+ import { PersistentSelectInput } from './PersistentSelectInput.js';
7
7
  import { ProcessManager, ConfigManager } from '../services/index.js';
8
8
  import { getFileNameFriendlyName, getProcessLogOutFilePath, getProcessLogErrorFilePath, getPomituSignalsDirectory } from '../helpers.js';
9
9
  import chokidar from 'chokidar';
@@ -18,8 +18,15 @@ export function ProcessTUI({ configPath, clearLogs }) {
18
18
  const [messageColor, setMessageColor] = useState('green');
19
19
  const [isProcessing, setIsProcessing] = useState(false);
20
20
  const [isReloading, setIsReloading] = useState(false);
21
- const [searchMode, setSearchMode] = useState(false);
21
+ const [searchActive, setSearchActive] = useState(false);
22
22
  const [searchQuery, setSearchQuery] = useState('');
23
+ // ESC via Ink's own pipeline — no readline disambiguation delay
24
+ useInput(useCallback((_input, key) => {
25
+ if (key.escape) {
26
+ setSearchActive(false);
27
+ setSearchQuery('');
28
+ }
29
+ }, []), { isActive: searchActive });
23
30
  const previousRawModeRef = useRef(false);
24
31
  const rawModeCapturedRef = useRef(false);
25
32
  const appsRef = useRef([]);
@@ -148,6 +155,13 @@ export function ProcessTUI({ configPath, clearLogs }) {
148
155
  appsRef.current = apps;
149
156
  apps.forEach(app => writeTuiPresence(app.name));
150
157
  }, [apps]);
158
+ // Heartbeat: refresh TUI presence file mtimes so isTuiActive can detect stale files
159
+ useEffect(() => {
160
+ const interval = setInterval(() => {
161
+ appsRef.current.forEach(app => writeTuiPresence(app.name));
162
+ }, 10000);
163
+ return () => clearInterval(interval);
164
+ }, []);
151
165
  // Set up chokidar watcher for IPC signals (mounted once; uses refs for latest state)
152
166
  useEffect(() => {
153
167
  const signalsDir = getPomituSignalsDirectory();
@@ -186,44 +200,39 @@ export function ProcessTUI({ configPath, clearLogs }) {
186
200
  setRawMode(true);
187
201
  }
188
202
  const handleKeypress = (str, key) => {
189
- // Handle search mode
190
- if (searchMode) {
191
- if (key?.name === 'escape') {
192
- setSearchMode(false);
193
- setSearchQuery('');
194
- return;
195
- }
203
+ if (key?.ctrl && key.name === 'c') {
204
+ cleanExit();
205
+ return;
206
+ }
207
+ if (searchActive) {
196
208
  if (key?.name === 'backspace') {
197
209
  setSearchQuery(prev => prev.slice(0, -1));
198
210
  return;
199
211
  }
200
- if (key?.name === 'return') {
201
- setSearchMode(false);
202
- return;
203
- }
204
- if (str && str.length === 1 && !key?.ctrl && !key?.meta) {
212
+ if (key?.name !== 'return' && str && str.length === 1 && !key?.ctrl && !key?.meta) {
205
213
  setSearchQuery(prev => prev + str);
206
- return;
207
214
  }
215
+ // ESC is handled by useInput (Ink pipeline); arrow keys, Enter, and other
216
+ // navigation are handled by PersistentSelectInput via Ink's input pipeline
208
217
  return;
209
218
  }
210
- // Normal mode
211
219
  if (str === 'q') {
212
220
  cleanExit();
213
221
  }
214
222
  if (str === 'r') {
215
223
  reloadConfig();
216
224
  }
217
- if (str === '/') {
218
- setSearchMode(true);
219
- setSearchQuery('');
225
+ if (str === 'e') {
226
+ open(configPath).catch(error => {
227
+ setMessage(`Failed to open config: ${error instanceof Error ? error.message : String(error)}`);
228
+ setMessageColor('red');
229
+ setTimeout(() => setMessage(''), 3000);
230
+ });
220
231
  }
221
- if (key?.name === 'escape' && searchQuery) {
232
+ if (str === '/') {
233
+ setSearchActive(true);
222
234
  setSearchQuery('');
223
235
  }
224
- if (key?.ctrl && key.name === 'c') {
225
- cleanExit();
226
- }
227
236
  };
228
237
  // Use prependListener to capture keys before other handlers
229
238
  stdin.prependListener('keypress', handleKeypress);
@@ -233,7 +242,7 @@ export function ProcessTUI({ configPath, clearLogs }) {
233
242
  setRawMode(previousRawModeRef.current);
234
243
  }
235
244
  };
236
- }, [stdin, setRawMode, cleanExit, reloadConfig, searchMode, searchQuery]);
245
+ }, [stdin, setRawMode, cleanExit, reloadConfig, searchActive]);
237
246
  // Handle Ctrl+C signal directly - use prependListener to be first
238
247
  useEffect(() => {
239
248
  const handleSigInt = () => {
@@ -305,6 +314,20 @@ export function ProcessTUI({ configPath, clearLogs }) {
305
314
  setIsProcessing(false);
306
315
  return;
307
316
  }
317
+ else if (action === 'opencwd') {
318
+ try {
319
+ await open(app.cwd);
320
+ setMessage('Opening working directory...');
321
+ setMessageColor('green');
322
+ }
323
+ catch (error) {
324
+ setMessage(`Failed to open directory: ${error instanceof Error ? error.message : String(error)}`);
325
+ setMessageColor('red');
326
+ }
327
+ setTimeout(() => setMessage(''), 3000);
328
+ setIsProcessing(false);
329
+ return;
330
+ }
308
331
  setProcesses(computeStatuses());
309
332
  }
310
333
  catch (error) {
@@ -318,9 +341,9 @@ export function ProcessTUI({ configPath, clearLogs }) {
318
341
  // Clear message after 3 seconds
319
342
  setTimeout(() => setMessage(''), 3000);
320
343
  }, [apps, clearLogs, computeStatuses, processManager, isProcessing, isReloading]);
321
- const getMenuItems = useCallback(() => {
344
+ const buildMenuItems = useCallback((statuses) => {
322
345
  const items = [];
323
- processes.forEach(proc => {
346
+ statuses.forEach(proc => {
324
347
  const statusLabel = proc.isRunning
325
348
  ? `🟢 Running (PID: ${proc.pid})`
326
349
  : '🔴 Stopped';
@@ -342,9 +365,13 @@ export function ProcessTUI({ configPath, clearLogs }) {
342
365
  value: `viewout:${proc.name}`
343
366
  });
344
367
  items.push({
345
- label: ' └─ View Error Log',
368
+ label: ' ├─ View Error Log',
346
369
  value: `viewerr:${proc.name}`
347
370
  });
371
+ items.push({
372
+ label: ' └─ Open Working Directory',
373
+ value: `opencwd:${proc.name}`
374
+ });
348
375
  }
349
376
  else {
350
377
  items.push({
@@ -356,21 +383,25 @@ export function ProcessTUI({ configPath, clearLogs }) {
356
383
  value: `viewout:${proc.name}`
357
384
  });
358
385
  items.push({
359
- label: ' └─ View Error Log',
386
+ label: ' ├─ View Error Log',
360
387
  value: `viewerr:${proc.name}`
361
388
  });
389
+ items.push({
390
+ label: ' └─ Open Working Directory',
391
+ value: `opencwd:${proc.name}`
392
+ });
362
393
  }
363
394
  });
364
395
  return items;
365
- }, [processes]);
396
+ }, []);
397
+ const allItems = useMemo(() => buildMenuItems(processes), [buildMenuItems, processes]);
366
398
  const items = useMemo(() => {
367
- const allItems = getMenuItems();
368
399
  if (!searchQuery)
369
400
  return allItems;
370
401
  const query = searchQuery.toLowerCase();
371
402
  return allItems.filter(item => item.label.toLowerCase().includes(query) ||
372
403
  item.value.toLowerCase().includes(query));
373
- }, [getMenuItems, searchQuery]);
404
+ }, [allItems, searchQuery]);
374
405
  const handleMenuSelect = useCallback((item) => {
375
406
  if (item.value === 'separator' || item.value.startsWith('info:')) {
376
407
  // Informational rows are read-only
@@ -392,23 +423,25 @@ export function ProcessTUI({ configPath, clearLogs }) {
392
423
  React.createElement(Text, { bold: true, color: "cyan" }, "Pomitu Process Manager - Interactive Mode")),
393
424
  processes.length > 0 ? (React.createElement(React.Fragment, null,
394
425
  React.createElement(Box, { marginBottom: 1 },
395
- React.createElement(Text, { dimColor: true }, "Use arrow keys to navigate, Enter to select, '/' to search, 'r' to reload, 'q' or Ctrl+C to quit")),
396
- searchMode && (React.createElement(Box, { marginBottom: 1 },
426
+ React.createElement(Text, { dimColor: true }, "Use arrow keys to navigate, Enter to select, '/' to search, 'r' to reload, 'e' to edit config, 'q' or Ctrl+C to quit")),
427
+ searchActive && (React.createElement(Box, { marginBottom: 1 },
397
428
  React.createElement(Text, { color: "yellow" },
398
429
  "Search: ",
399
430
  searchQuery),
400
- React.createElement(Text, { dimColor: true }, " (ESC to cancel, Enter to apply)"))),
401
- searchQuery && !searchMode && (React.createElement(Box, { marginBottom: 1 },
402
- React.createElement(Text, { color: "green" },
403
- "Filtering: ",
404
- searchQuery),
405
- React.createElement(Text, { dimColor: true }, " (/ to edit, ESC to clear)"))),
406
- items.length > 15 && (React.createElement(Box, { marginBottom: 1 },
431
+ React.createElement(Text, { dimColor: true }, " (ESC to clear)"))),
432
+ searchActive && searchQuery && items.length < allItems.length && (React.createElement(Box, { marginBottom: 1 },
433
+ React.createElement(Text, { dimColor: true },
434
+ "Showing ",
435
+ items.length,
436
+ " of ",
437
+ allItems.length,
438
+ " items"))),
439
+ !searchActive && items.length > 15 && (React.createElement(Box, { marginBottom: 1 },
407
440
  React.createElement(Text, { dimColor: true },
408
441
  "Showing 15 of ",
409
442
  items.length,
410
443
  " items - scroll with \u2191\u2193 arrows"))),
411
- React.createElement(SelectInput, { items: items, onSelect: handleMenuSelect, isFocused: !isProcessing && !isReloading && !searchMode, limit: 15 }))) : (React.createElement(Text, null, "Loading processes...")),
444
+ React.createElement(PersistentSelectInput, { items: items, onSelect: handleMenuSelect, isFocused: !isProcessing && !isReloading, limit: 15, headingPredicate: item => item.value.startsWith('info:') }))) : (React.createElement(Text, null, "Loading processes...")),
412
445
  React.createElement(Box, { marginTop: 1 },
413
446
  React.createElement(Text, { color: notificationColor ?? 'gray' }, notificationText ?? ' '))));
414
447
  }
@@ -13,14 +13,19 @@ export function clearTuiPresence(appName) {
13
13
  fs.unlinkSync(tuiPidPath);
14
14
  }
15
15
  }
16
+ const TUI_HEARTBEAT_MAX_AGE_MS = 30000;
16
17
  export function isTuiActive(appName) {
17
18
  const tuiPidPath = getTuiPidPath(getFileNameFriendlyName(appName));
18
19
  if (!fs.existsSync(tuiPidPath)) {
19
20
  return false;
20
21
  }
21
22
  try {
22
- const pid = parseInt(fs.readFileSync(tuiPidPath, 'utf-8'));
23
- return !isNaN(pid) && pidIsRunning(pid);
23
+ const stat = fs.statSync(tuiPidPath);
24
+ if (Date.now() - stat.mtimeMs > TUI_HEARTBEAT_MAX_AGE_MS) {
25
+ fs.unlinkSync(tuiPidPath);
26
+ return false;
27
+ }
28
+ return true;
24
29
  }
25
30
  catch {
26
31
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pomitu",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Pomitu is a process manager inspired by PM2",
5
5
  "type": "module",
6
6
  "scripts": {