pomitu 1.5.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.5.0.tgz && rm pomitu-1.5.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,
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { isDeepStrictEqual } from 'node:util';
6
6
  import React, { useState, useEffect, useRef, useCallback } from 'react';
7
- import { Box, useInput } from 'ink';
7
+ import { Box, useInput, useStdin } from 'ink';
8
8
  import { Indicator, Item as ItemComponent } from 'ink-select-input';
9
9
  function rotateArray(arr, k) {
10
10
  if (arr.length === 0)
@@ -15,13 +15,50 @@ function rotateArray(arr, k) {
15
15
  return [...arr];
16
16
  return [...arr.slice(-steps), ...arr.slice(0, -steps)];
17
17
  }
18
- export function PersistentSelectInput({ items = [], isFocused = true, initialIndex = 0, indicatorComponent: IndicatorComp = Indicator, itemComponent: ItemComp = ItemComponent, limit: customLimit, onSelect, onHighlight, }) {
18
+ export function PersistentSelectInput({ items = [], isFocused = true, initialIndex = 0, indicatorComponent: IndicatorComp = Indicator, itemComponent: ItemComp = ItemComponent, limit: customLimit, onSelect, onHighlight, headingPredicate, }) {
19
19
  const hasLimit = typeof customLimit === 'number' && items.length > customLimit;
20
20
  const limit = hasLimit ? Math.min(customLimit, items.length) : items.length;
21
21
  const lastIndex = limit - 1;
22
22
  const [rotateIndex, setRotateIndex] = useState(initialIndex > lastIndex ? lastIndex - initialIndex : 0);
23
23
  const [selectedIndex, setSelectedIndex] = useState(initialIndex ? (initialIndex > lastIndex ? lastIndex : initialIndex) : 0);
24
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]);
25
62
  useEffect(() => {
26
63
  if (!isDeepStrictEqual(previousItems.current.map(item => item.value), items.map(item => item.value))) {
27
64
  const newHasLimit = typeof customLimit === 'number' && items.length > customLimit;
@@ -101,6 +138,60 @@ export function PersistentSelectInput({ items = [], isFocused = true, initialInd
101
138
  onHighlight(items[nextSelectedIndex]);
102
139
  }
103
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
+ }
104
195
  if (/^[1-9]$/.test(input)) {
105
196
  const targetIndex = Number.parseInt(input, 10) - 1;
106
197
  const visibleItems = hasLimit
@@ -121,7 +212,7 @@ export function PersistentSelectInput({ items = [], isFocused = true, initialInd
121
212
  onSelect(slicedItems[selectedIndex]);
122
213
  }
123
214
  }
124
- }, [hasLimit, limit, rotateIndex, selectedIndex, items, onSelect, onHighlight]), { isActive: isFocused });
215
+ }, [hasLimit, limit, rotateIndex, selectedIndex, items, onSelect, onHighlight, headingPredicate]), { isActive: isFocused });
125
216
  const slicedItems = hasLimit
126
217
  ? rotateArray(items, rotateIndex).slice(0, limit)
127
218
  : items;
@@ -2,7 +2,7 @@ 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';
5
+ import { Box, Text, useApp, useStdin, useInput } from 'ink';
6
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';
@@ -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,25 +200,20 @@ 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
219
  if (str === 'q') {
@@ -213,16 +222,17 @@ export function ProcessTUI({ configPath, clearLogs }) {
213
222
  if (str === 'r') {
214
223
  reloadConfig();
215
224
  }
216
- if (str === '/') {
217
- setSearchMode(true);
218
- 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
+ });
219
231
  }
220
- if (key?.name === 'escape' && searchQuery) {
232
+ if (str === '/') {
233
+ setSearchActive(true);
221
234
  setSearchQuery('');
222
235
  }
223
- if (key?.ctrl && key.name === 'c') {
224
- cleanExit();
225
- }
226
236
  };
227
237
  // Use prependListener to capture keys before other handlers
228
238
  stdin.prependListener('keypress', handleKeypress);
@@ -232,7 +242,7 @@ export function ProcessTUI({ configPath, clearLogs }) {
232
242
  setRawMode(previousRawModeRef.current);
233
243
  }
234
244
  };
235
- }, [stdin, setRawMode, cleanExit, reloadConfig, searchMode, searchQuery]);
245
+ }, [stdin, setRawMode, cleanExit, reloadConfig, searchActive]);
236
246
  // Handle Ctrl+C signal directly - use prependListener to be first
237
247
  useEffect(() => {
238
248
  const handleSigInt = () => {
@@ -304,6 +314,20 @@ export function ProcessTUI({ configPath, clearLogs }) {
304
314
  setIsProcessing(false);
305
315
  return;
306
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
+ }
307
331
  setProcesses(computeStatuses());
308
332
  }
309
333
  catch (error) {
@@ -341,9 +365,13 @@ export function ProcessTUI({ configPath, clearLogs }) {
341
365
  value: `viewout:${proc.name}`
342
366
  });
343
367
  items.push({
344
- label: ' └─ View Error Log',
368
+ label: ' ├─ View Error Log',
345
369
  value: `viewerr:${proc.name}`
346
370
  });
371
+ items.push({
372
+ label: ' └─ Open Working Directory',
373
+ value: `opencwd:${proc.name}`
374
+ });
347
375
  }
348
376
  else {
349
377
  items.push({
@@ -355,21 +383,25 @@ export function ProcessTUI({ configPath, clearLogs }) {
355
383
  value: `viewout:${proc.name}`
356
384
  });
357
385
  items.push({
358
- label: ' └─ View Error Log',
386
+ label: ' ├─ View Error Log',
359
387
  value: `viewerr:${proc.name}`
360
388
  });
389
+ items.push({
390
+ label: ' └─ Open Working Directory',
391
+ value: `opencwd:${proc.name}`
392
+ });
361
393
  }
362
394
  });
363
395
  return items;
364
396
  }, []);
397
+ const allItems = useMemo(() => buildMenuItems(processes), [buildMenuItems, processes]);
365
398
  const items = useMemo(() => {
366
- const allItems = buildMenuItems(processes);
367
399
  if (!searchQuery)
368
400
  return allItems;
369
401
  const query = searchQuery.toLowerCase();
370
402
  return allItems.filter(item => item.label.toLowerCase().includes(query) ||
371
403
  item.value.toLowerCase().includes(query));
372
- }, [buildMenuItems, processes, searchQuery]);
404
+ }, [allItems, searchQuery]);
373
405
  const handleMenuSelect = useCallback((item) => {
374
406
  if (item.value === 'separator' || item.value.startsWith('info:')) {
375
407
  // Informational rows are read-only
@@ -391,23 +423,25 @@ export function ProcessTUI({ configPath, clearLogs }) {
391
423
  React.createElement(Text, { bold: true, color: "cyan" }, "Pomitu Process Manager - Interactive Mode")),
392
424
  processes.length > 0 ? (React.createElement(React.Fragment, null,
393
425
  React.createElement(Box, { marginBottom: 1 },
394
- React.createElement(Text, { dimColor: true }, "Use arrow keys to navigate, Enter to select, '/' to search, 'r' to reload, 'q' or Ctrl+C to quit")),
395
- 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 },
396
428
  React.createElement(Text, { color: "yellow" },
397
429
  "Search: ",
398
430
  searchQuery),
399
- React.createElement(Text, { dimColor: true }, " (ESC to cancel, Enter to apply)"))),
400
- searchQuery && !searchMode && (React.createElement(Box, { marginBottom: 1 },
401
- React.createElement(Text, { color: "green" },
402
- "Filtering: ",
403
- searchQuery),
404
- React.createElement(Text, { dimColor: true }, " (/ to edit, ESC to clear)"))),
405
- 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 },
406
440
  React.createElement(Text, { dimColor: true },
407
441
  "Showing 15 of ",
408
442
  items.length,
409
443
  " items - scroll with \u2191\u2193 arrows"))),
410
- React.createElement(PersistentSelectInput, { 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...")),
411
445
  React.createElement(Box, { marginTop: 1 },
412
446
  React.createElement(Text, { color: notificationColor ?? 'gray' }, notificationText ?? ' '))));
413
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.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Pomitu is a process manager inspired by PM2",
5
5
  "type": "module",
6
6
  "scripts": {