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.
|
|
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
|
package/dist/commands/start.js
CHANGED
|
@@ -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 [
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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
|
-
|
|
218
|
-
|
|
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 (
|
|
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,
|
|
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: '
|
|
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: '
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
|
400
|
-
searchQuery &&
|
|
401
|
-
React.createElement(Text, {
|
|
402
|
-
"
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
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
|
|
23
|
-
|
|
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;
|