@pokit/tabs-ink 0.0.1 → 0.0.3
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/package.json +9 -9
- package/src/adapter.tsx +223 -0
- package/src/error-boundary.tsx +90 -0
- package/src/event-driven-app.tsx +249 -0
- package/src/help-overlay.tsx +40 -0
- package/src/tabbed-view.tsx +347 -0
- package/src/tabs-app.tsx +313 -0
- package/src/types.ts +39 -0
- package/src/use-event-bus.ts +28 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pokit/tabs-ink",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Ink-based tab renderer for pok CLI applications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -26,9 +26,8 @@
|
|
|
26
26
|
"bugs": {
|
|
27
27
|
"url": "https://github.com/notation-dev/openpok/issues"
|
|
28
28
|
},
|
|
29
|
-
"main": "./
|
|
30
|
-
"
|
|
31
|
-
"types": "./src/index.ts",
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
32
31
|
"exports": {
|
|
33
32
|
".": {
|
|
34
33
|
"bun": "./src/index.ts",
|
|
@@ -39,7 +38,8 @@
|
|
|
39
38
|
"files": [
|
|
40
39
|
"dist",
|
|
41
40
|
"README.md",
|
|
42
|
-
"LICENSE"
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"src"
|
|
43
43
|
],
|
|
44
44
|
"publishConfig": {
|
|
45
45
|
"access": "public"
|
|
@@ -51,12 +51,12 @@
|
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/bun": "latest",
|
|
53
53
|
"@types/react": "^19.2.0",
|
|
54
|
-
"@pokit/core": "0.0.
|
|
55
|
-
"@pokit/tabs-core": "0.0.
|
|
54
|
+
"@pokit/core": "0.0.3",
|
|
55
|
+
"@pokit/tabs-core": "0.0.3"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@pokit/core": "0.0.
|
|
59
|
-
"@pokit/tabs-core": "0.0.
|
|
58
|
+
"@pokit/core": "0.0.3",
|
|
59
|
+
"@pokit/tabs-core": "0.0.3"
|
|
60
60
|
},
|
|
61
61
|
"engines": {
|
|
62
62
|
"bun": ">=1.0.0"
|
package/src/adapter.tsx
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ink-based Tabs Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the TabsAdapter interface using Ink (React for CLI).
|
|
5
|
+
* Also provides an event-driven adapter that renders based on EventBus events.
|
|
6
|
+
*/
|
|
7
|
+
import { render, type RenderOptions } from 'ink';
|
|
8
|
+
import type { TabsAdapter, TabSpec, TabsOptions, EventBus } from '@pokit/core';
|
|
9
|
+
import { TabsApp } from './tabs-app.js';
|
|
10
|
+
import { EventDrivenApp } from './event-driven-app.js';
|
|
11
|
+
import { TabsErrorBoundary, restoreTerminal } from './error-boundary.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a tabs adapter using Ink
|
|
15
|
+
*/
|
|
16
|
+
export function createTabsAdapter(): TabsAdapter {
|
|
17
|
+
return {
|
|
18
|
+
async run(items: TabSpec[], options: TabsOptions): Promise<void> {
|
|
19
|
+
// Check stdout TTY
|
|
20
|
+
if (!process.stdout.isTTY) {
|
|
21
|
+
throw new Error('Tabbed view requires stdout to be a TTY');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Check stdin TTY - required for keyboard input
|
|
25
|
+
if (!process.stdin.isTTY) {
|
|
26
|
+
throw new Error('Tabbed view requires stdin to be a TTY for keyboard input');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Empty items - nothing to do
|
|
30
|
+
if (items.length === 0) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Switch to alternate screen buffer (like vim/less)
|
|
35
|
+
// This preserves the main terminal content and provides a clean canvas
|
|
36
|
+
process.stdout.write('\x1b[?1049h\x1b[H');
|
|
37
|
+
|
|
38
|
+
// Ensure stdin is not paused - previous CLI operations (prompts, spinners)
|
|
39
|
+
// may have left it in a paused state
|
|
40
|
+
if (process.stdin.isPaused()) {
|
|
41
|
+
process.stdin.resume();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Enable raw mode before Ink starts - Ink's internal ref counting may be
|
|
45
|
+
// out of sync if previous CLI operations (clack prompts/spinners) didn't
|
|
46
|
+
// properly balance their setRawMode calls
|
|
47
|
+
process.stdin.setRawMode(true);
|
|
48
|
+
|
|
49
|
+
return new Promise<void>((resolve) => {
|
|
50
|
+
let resolved = false;
|
|
51
|
+
let unmount: (() => void) | null = null;
|
|
52
|
+
let clear: (() => void) | null = null;
|
|
53
|
+
|
|
54
|
+
// Cleanup function to restore terminal and resolve
|
|
55
|
+
const cleanup = () => {
|
|
56
|
+
if (resolved) return;
|
|
57
|
+
resolved = true;
|
|
58
|
+
|
|
59
|
+
// Remove signal handlers
|
|
60
|
+
process.removeListener('SIGTERM', handleSignal);
|
|
61
|
+
process.removeListener('SIGQUIT', handleSignal);
|
|
62
|
+
process.removeListener('uncaughtException', handleUncaughtException);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
clear?.();
|
|
66
|
+
unmount?.();
|
|
67
|
+
} catch {
|
|
68
|
+
// Ignore errors during cleanup
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
restoreTerminal();
|
|
72
|
+
resolve();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Signal handler for graceful shutdown
|
|
76
|
+
const handleSignal = () => {
|
|
77
|
+
cleanup();
|
|
78
|
+
process.exit(0);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Handle uncaught exceptions
|
|
82
|
+
const handleUncaughtException = (error: Error) => {
|
|
83
|
+
restoreTerminal();
|
|
84
|
+
console.error('\n[TabsUI] Uncaught exception:', error);
|
|
85
|
+
cleanup();
|
|
86
|
+
process.exit(1);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Register signal handlers
|
|
90
|
+
process.on('SIGTERM', handleSignal);
|
|
91
|
+
process.on('SIGQUIT', handleSignal);
|
|
92
|
+
process.on('uncaughtException', handleUncaughtException);
|
|
93
|
+
|
|
94
|
+
// Handle fatal errors from error boundary
|
|
95
|
+
const handleFatalError = () => {
|
|
96
|
+
cleanup();
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const result = render(
|
|
100
|
+
<TabsErrorBoundary onFatalError={handleFatalError}>
|
|
101
|
+
<TabsApp
|
|
102
|
+
items={items}
|
|
103
|
+
options={options}
|
|
104
|
+
onExit={() => {
|
|
105
|
+
cleanup();
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
</TabsErrorBoundary>,
|
|
109
|
+
{
|
|
110
|
+
exitOnCtrlC: false, // We handle quit ourselves
|
|
111
|
+
incrementalRendering: true, // Only update changed lines to reduce flicker
|
|
112
|
+
} as RenderOptions
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
unmount = result.unmount;
|
|
116
|
+
clear = result.clear;
|
|
117
|
+
|
|
118
|
+
// Also resolve when ink exits naturally
|
|
119
|
+
result.waitUntilExit().then(() => {
|
|
120
|
+
if (!resolved) {
|
|
121
|
+
cleanup();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// =============================================================================
|
|
130
|
+
// Event-Driven Adapter
|
|
131
|
+
// =============================================================================
|
|
132
|
+
|
|
133
|
+
export type EventAdapterOptions = {
|
|
134
|
+
onExit?: (code: number) => void;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Create an event-driven adapter that renders based on EventBus events.
|
|
139
|
+
* This adapter builds a state tree from events and renders it using Ink.
|
|
140
|
+
*
|
|
141
|
+
* @param bus - The EventBus to listen to
|
|
142
|
+
* @param options - Options for the adapter
|
|
143
|
+
* @returns Object with unmount function to stop the adapter
|
|
144
|
+
*/
|
|
145
|
+
export function createEventAdapter(
|
|
146
|
+
bus: EventBus,
|
|
147
|
+
options: EventAdapterOptions = {}
|
|
148
|
+
): { unmount: () => void } {
|
|
149
|
+
if (!process.stdout.isTTY) {
|
|
150
|
+
throw new Error('Event-driven tabs view requires stdout to be a TTY');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!process.stdin.isTTY) {
|
|
154
|
+
throw new Error('Event-driven tabs view requires stdin to be a TTY for keyboard input');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
process.stdout.write('\x1b[?1049h\x1b[H');
|
|
158
|
+
|
|
159
|
+
let isCleanedUp = false;
|
|
160
|
+
|
|
161
|
+
// Cleanup function to restore terminal
|
|
162
|
+
const cleanup = () => {
|
|
163
|
+
if (isCleanedUp) return;
|
|
164
|
+
isCleanedUp = true;
|
|
165
|
+
|
|
166
|
+
// Remove signal handlers
|
|
167
|
+
process.removeListener('SIGTERM', handleSignal);
|
|
168
|
+
process.removeListener('SIGQUIT', handleSignal);
|
|
169
|
+
process.removeListener('uncaughtException', handleUncaughtException);
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
clear();
|
|
173
|
+
unmount();
|
|
174
|
+
} catch {
|
|
175
|
+
// Ignore errors during cleanup
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
restoreTerminal();
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Signal handler for graceful shutdown
|
|
182
|
+
const handleSignal = () => {
|
|
183
|
+
cleanup();
|
|
184
|
+
process.exit(0);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Handle uncaught exceptions
|
|
188
|
+
const handleUncaughtException = (error: Error) => {
|
|
189
|
+
restoreTerminal();
|
|
190
|
+
console.error('\n[TabsUI] Uncaught exception:', error);
|
|
191
|
+
cleanup();
|
|
192
|
+
process.exit(1);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Register signal handlers
|
|
196
|
+
process.on('SIGTERM', handleSignal);
|
|
197
|
+
process.on('SIGQUIT', handleSignal);
|
|
198
|
+
process.on('uncaughtException', handleUncaughtException);
|
|
199
|
+
|
|
200
|
+
const handleExit = (code: number) => {
|
|
201
|
+
cleanup();
|
|
202
|
+
options.onExit?.(code);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Handle fatal errors from error boundary
|
|
206
|
+
const handleFatalError = () => {
|
|
207
|
+
cleanup();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const { unmount, clear } = render(
|
|
211
|
+
<TabsErrorBoundary onFatalError={handleFatalError}>
|
|
212
|
+
<EventDrivenApp bus={bus} onExit={handleExit} />
|
|
213
|
+
</TabsErrorBoundary>,
|
|
214
|
+
{
|
|
215
|
+
exitOnCtrlC: false,
|
|
216
|
+
incrementalRendering: true,
|
|
217
|
+
} as RenderOptions
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
unmount: cleanup,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error Boundary for Tabs UI
|
|
3
|
+
*
|
|
4
|
+
* Catches React errors and ensures terminal state is restored before displaying error.
|
|
5
|
+
* This prevents the terminal from being left in a corrupted state (alternate screen,
|
|
6
|
+
* hidden cursor, raw mode) when React rendering crashes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Component, type ReactNode, type ErrorInfo } from 'react';
|
|
10
|
+
import { Text, Box } from 'ink';
|
|
11
|
+
|
|
12
|
+
export type ErrorBoundaryProps = {
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
onFatalError?: (error: Error, errorInfo: ErrorInfo) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ErrorBoundaryState = {
|
|
18
|
+
hasError: boolean;
|
|
19
|
+
error: Error | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Restore terminal to a clean state.
|
|
24
|
+
* Called on errors and signal handlers to ensure terminal isn't left corrupted.
|
|
25
|
+
*/
|
|
26
|
+
export function restoreTerminal(): void {
|
|
27
|
+
// Exit alternate screen buffer
|
|
28
|
+
process.stdout.write('\x1b[?1049l');
|
|
29
|
+
// Show cursor (in case it was hidden)
|
|
30
|
+
process.stdout.write('\x1b[?25h');
|
|
31
|
+
// Reset all attributes (colors, styles)
|
|
32
|
+
process.stdout.write('\x1b[0m');
|
|
33
|
+
|
|
34
|
+
// Try to restore raw mode if stdin is a TTY
|
|
35
|
+
if (process.stdin.isTTY && process.stdin.isRaw) {
|
|
36
|
+
try {
|
|
37
|
+
process.stdin.setRawMode(false);
|
|
38
|
+
} catch {
|
|
39
|
+
// Ignore errors - stdin may already be closed
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* React Error Boundary that ensures terminal cleanup on crashes.
|
|
46
|
+
*/
|
|
47
|
+
export class TabsErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
48
|
+
constructor(props: ErrorBoundaryProps) {
|
|
49
|
+
super(props);
|
|
50
|
+
this.state = { hasError: false, error: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
54
|
+
return { hasError: true, error };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
|
58
|
+
// Restore terminal state immediately
|
|
59
|
+
restoreTerminal();
|
|
60
|
+
|
|
61
|
+
// Log error details for debugging
|
|
62
|
+
console.error('\n[TabsUI] Fatal error caught by error boundary:');
|
|
63
|
+
console.error(error);
|
|
64
|
+
if (errorInfo.componentStack) {
|
|
65
|
+
console.error('\nComponent stack:', errorInfo.componentStack);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Notify parent if callback provided
|
|
69
|
+
this.props.onFatalError?.(error, errorInfo);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
render(): ReactNode {
|
|
73
|
+
if (this.state.hasError) {
|
|
74
|
+
// Show minimal error message - terminal should already be restored
|
|
75
|
+
return (
|
|
76
|
+
<Box flexDirection="column" padding={1}>
|
|
77
|
+
<Text color="red" bold>
|
|
78
|
+
TabsUI encountered a fatal error
|
|
79
|
+
</Text>
|
|
80
|
+
<Text color="gray">{this.state.error?.message ?? 'Unknown error'}</Text>
|
|
81
|
+
<Text color="gray" dimColor>
|
|
82
|
+
Press Ctrl+C to exit
|
|
83
|
+
</Text>
|
|
84
|
+
</Box>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return this.props.children;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event-Driven CLI App
|
|
3
|
+
*
|
|
4
|
+
* A React component that renders CLI state based on EventBus events.
|
|
5
|
+
* Supports different layouts (tabs, parallel, sequence) based on group layout hints.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useCallback } from 'react';
|
|
9
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
10
|
+
import type { EventBus } from '@pokit/core';
|
|
11
|
+
import type { ActivityNode } from '@pokit/tabs-core';
|
|
12
|
+
import { findTabsGroup, getTabsGroupActivities } from '@pokit/tabs-core';
|
|
13
|
+
import { useEventBus } from './use-event-bus.js';
|
|
14
|
+
|
|
15
|
+
type EventDrivenAppProps = {
|
|
16
|
+
bus: EventBus;
|
|
17
|
+
onExit: (code: number) => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function getStatusIndicator({
|
|
21
|
+
status,
|
|
22
|
+
inverse,
|
|
23
|
+
}: {
|
|
24
|
+
status: ActivityNode['status'];
|
|
25
|
+
inverse?: boolean;
|
|
26
|
+
}) {
|
|
27
|
+
switch (status) {
|
|
28
|
+
case 'running':
|
|
29
|
+
return { color: inverse ? 'cyanBright' : 'cyan', icon: '●' };
|
|
30
|
+
case 'success':
|
|
31
|
+
return { color: inverse ? 'greenBright' : 'green', icon: '✓' };
|
|
32
|
+
case 'failure':
|
|
33
|
+
return { color: inverse ? 'redBright' : 'red', icon: '✗' };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ActivityTabBar({
|
|
38
|
+
activities,
|
|
39
|
+
activeIndex,
|
|
40
|
+
}: {
|
|
41
|
+
activities: ActivityNode[];
|
|
42
|
+
activeIndex: number;
|
|
43
|
+
}) {
|
|
44
|
+
return (
|
|
45
|
+
<Box gap={1} flexWrap="wrap">
|
|
46
|
+
{activities.map((activity, i) => {
|
|
47
|
+
const isActive = i === activeIndex;
|
|
48
|
+
const { color, icon } = getStatusIndicator({
|
|
49
|
+
status: activity.status,
|
|
50
|
+
inverse: isActive,
|
|
51
|
+
});
|
|
52
|
+
return (
|
|
53
|
+
<Box key={activity.id}>
|
|
54
|
+
<Text inverse={isActive} color={color}>
|
|
55
|
+
{' '}
|
|
56
|
+
{icon}{' '}
|
|
57
|
+
</Text>
|
|
58
|
+
<Text inverse={isActive}> {activity.label}</Text>
|
|
59
|
+
<Text inverse={isActive}>
|
|
60
|
+
{' ('}
|
|
61
|
+
{i + 1}
|
|
62
|
+
{') '}
|
|
63
|
+
</Text>
|
|
64
|
+
</Box>
|
|
65
|
+
);
|
|
66
|
+
})}
|
|
67
|
+
</Box>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ActivityView({ activity, viewHeight }: { activity: ActivityNode; viewHeight: number }) {
|
|
72
|
+
const logs = activity.logs.slice(-viewHeight);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Box flexDirection="column" height={viewHeight} overflow="hidden">
|
|
76
|
+
{activity.message && <Text color="cyan">{activity.message}</Text>}
|
|
77
|
+
{activity.progress !== undefined && (
|
|
78
|
+
<Text color="yellow">Progress: {activity.progress}%</Text>
|
|
79
|
+
)}
|
|
80
|
+
{logs.map((log, i) => {
|
|
81
|
+
let color: string | undefined;
|
|
82
|
+
switch (log.level) {
|
|
83
|
+
case 'error':
|
|
84
|
+
color = 'red';
|
|
85
|
+
break;
|
|
86
|
+
case 'warn':
|
|
87
|
+
color = 'yellow';
|
|
88
|
+
break;
|
|
89
|
+
case 'success':
|
|
90
|
+
color = 'green';
|
|
91
|
+
break;
|
|
92
|
+
case 'info':
|
|
93
|
+
color = 'blue';
|
|
94
|
+
break;
|
|
95
|
+
default:
|
|
96
|
+
color = undefined;
|
|
97
|
+
}
|
|
98
|
+
return (
|
|
99
|
+
<Text key={i} color={color} wrap="truncate">
|
|
100
|
+
{log.message}
|
|
101
|
+
</Text>
|
|
102
|
+
);
|
|
103
|
+
})}
|
|
104
|
+
{Array.from({
|
|
105
|
+
length: Math.max(
|
|
106
|
+
0,
|
|
107
|
+
viewHeight -
|
|
108
|
+
logs.length -
|
|
109
|
+
(activity.message ? 1 : 0) -
|
|
110
|
+
(activity.progress !== undefined ? 1 : 0)
|
|
111
|
+
),
|
|
112
|
+
}).map((_, i) => (
|
|
113
|
+
<Text key={`empty-${i}`}> </Text>
|
|
114
|
+
))}
|
|
115
|
+
</Box>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function StatusBar({
|
|
120
|
+
activityCount,
|
|
121
|
+
quitConfirmPending,
|
|
122
|
+
}: {
|
|
123
|
+
activityCount: number;
|
|
124
|
+
quitConfirmPending: boolean;
|
|
125
|
+
}) {
|
|
126
|
+
if (quitConfirmPending) {
|
|
127
|
+
return (
|
|
128
|
+
<Box>
|
|
129
|
+
<Text backgroundColor="yellow" color="black">
|
|
130
|
+
{' '}
|
|
131
|
+
Press q again to quit, any other key to cancel{' '}
|
|
132
|
+
</Text>
|
|
133
|
+
</Box>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<Box>
|
|
139
|
+
<Text dimColor>[Tab/1-{activityCount}] switch | [q]uit</Text>
|
|
140
|
+
</Box>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function EventDrivenApp({ bus, onExit }: EventDrivenAppProps) {
|
|
145
|
+
const state = useEventBus(bus);
|
|
146
|
+
const { stdout } = useStdout();
|
|
147
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
148
|
+
const [quitConfirmPending, setQuitConfirmPending] = useState(false);
|
|
149
|
+
|
|
150
|
+
const terminalHeight = stdout?.rows ?? 24;
|
|
151
|
+
const viewHeight = Math.max(5, terminalHeight - 6);
|
|
152
|
+
|
|
153
|
+
const tabsGroup = findTabsGroup(state);
|
|
154
|
+
const activities = tabsGroup
|
|
155
|
+
? getTabsGroupActivities(state, tabsGroup.id)
|
|
156
|
+
: Array.from(state.activities.values());
|
|
157
|
+
|
|
158
|
+
const activeActivity = activities[activeIndex];
|
|
159
|
+
|
|
160
|
+
const switchTab = useCallback(
|
|
161
|
+
(newIndex: number) => {
|
|
162
|
+
if (newIndex >= 0 && newIndex < activities.length) {
|
|
163
|
+
setActiveIndex(newIndex);
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
[activities.length]
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const handleQuitRequest = useCallback(() => {
|
|
170
|
+
setQuitConfirmPending((prev) => !prev);
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
const handleQuit = useCallback(() => {
|
|
174
|
+
onExit(state.exitCode ?? 0);
|
|
175
|
+
}, [onExit, state.exitCode]);
|
|
176
|
+
|
|
177
|
+
useInput(
|
|
178
|
+
(input, key) => {
|
|
179
|
+
if (quitConfirmPending) {
|
|
180
|
+
if (input === 'q') {
|
|
181
|
+
handleQuit();
|
|
182
|
+
} else {
|
|
183
|
+
handleQuitRequest();
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (input === 'q') {
|
|
189
|
+
handleQuitRequest();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (input === 'c' && key.ctrl) {
|
|
194
|
+
handleQuit();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const num = parseInt(input, 10);
|
|
199
|
+
if (num >= 1 && num <= activities.length) {
|
|
200
|
+
switchTab(num - 1);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (key.tab && key.shift) {
|
|
205
|
+
switchTab((activeIndex - 1 + activities.length) % activities.length);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (key.tab) {
|
|
209
|
+
switchTab((activeIndex + 1) % activities.length);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
{ isActive: true }
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (activities.length === 0) {
|
|
217
|
+
return (
|
|
218
|
+
<Box flexDirection="column" padding={1}>
|
|
219
|
+
<Text>Waiting for activities...</Text>
|
|
220
|
+
{state.appName && (
|
|
221
|
+
<Text dimColor>
|
|
222
|
+
{state.appName}
|
|
223
|
+
{state.version ? ` v${state.version}` : ''}
|
|
224
|
+
</Text>
|
|
225
|
+
)}
|
|
226
|
+
</Box>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!activeActivity) {
|
|
231
|
+
return <Text>No active activity</Text>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<Box flexDirection="column" padding={1}>
|
|
236
|
+
<ActivityTabBar activities={activities} activeIndex={activeIndex} />
|
|
237
|
+
<Box
|
|
238
|
+
borderLeft={false}
|
|
239
|
+
borderRight={false}
|
|
240
|
+
borderStyle="single"
|
|
241
|
+
borderColor="gray"
|
|
242
|
+
paddingX={1}
|
|
243
|
+
>
|
|
244
|
+
<ActivityView activity={activeActivity} viewHeight={viewHeight} />
|
|
245
|
+
</Box>
|
|
246
|
+
<StatusBar activityCount={activities.length} quitConfirmPending={quitConfirmPending} />
|
|
247
|
+
</Box>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import { HELP_CONTENT } from '@pokit/tabs-core';
|
|
3
|
+
|
|
4
|
+
export type HelpOverlayProps = {
|
|
5
|
+
onClose: () => void;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function HelpOverlay(_props: HelpOverlayProps) {
|
|
9
|
+
return (
|
|
10
|
+
<Box flexDirection="column" borderStyle="single" borderColor="blue" paddingX={2} paddingY={1}>
|
|
11
|
+
<Box justifyContent="center" marginBottom={1}>
|
|
12
|
+
<Text bold>Keyboard Help</Text>
|
|
13
|
+
</Box>
|
|
14
|
+
|
|
15
|
+
{HELP_CONTENT.map((group, i) => (
|
|
16
|
+
<Box
|
|
17
|
+
key={group.title}
|
|
18
|
+
flexDirection="column"
|
|
19
|
+
marginBottom={i < HELP_CONTENT.length - 1 ? 1 : 0}
|
|
20
|
+
>
|
|
21
|
+
<Text bold color="cyan">
|
|
22
|
+
{group.title}
|
|
23
|
+
</Text>
|
|
24
|
+
{group.shortcuts.map(({ key, description }) => (
|
|
25
|
+
<Box key={key}>
|
|
26
|
+
<Box width={16}>
|
|
27
|
+
<Text color="yellow">{key}</Text>
|
|
28
|
+
</Box>
|
|
29
|
+
<Text>{description}</Text>
|
|
30
|
+
</Box>
|
|
31
|
+
))}
|
|
32
|
+
</Box>
|
|
33
|
+
))}
|
|
34
|
+
|
|
35
|
+
<Box marginTop={1} justifyContent="center">
|
|
36
|
+
<Text dimColor>Press ? or Escape to close</Text>
|
|
37
|
+
</Box>
|
|
38
|
+
</Box>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
2
|
+
import type { TabProcess } from '@pokit/tabs-core';
|
|
3
|
+
import {
|
|
4
|
+
useTabsState,
|
|
5
|
+
useKeyboardCallbackRefs,
|
|
6
|
+
processKeyEvent,
|
|
7
|
+
executeKeyboardAction,
|
|
8
|
+
type NormalizedKeyEvent,
|
|
9
|
+
type KeyboardCallbacks,
|
|
10
|
+
} from '@pokit/tabs-core';
|
|
11
|
+
import { HelpOverlay } from './help-overlay.js';
|
|
12
|
+
|
|
13
|
+
type TabbedViewProps = {
|
|
14
|
+
tabs: TabProcess[];
|
|
15
|
+
activeIndex: number;
|
|
16
|
+
onActiveIndexChange: (index: number) => void;
|
|
17
|
+
onQuit: () => void;
|
|
18
|
+
onQuitRequest: () => void;
|
|
19
|
+
onRestart: (index: number) => void;
|
|
20
|
+
onKill: (index: number) => void;
|
|
21
|
+
quitConfirmPending: boolean;
|
|
22
|
+
focusMode: boolean;
|
|
23
|
+
onEnterFocusMode: () => void;
|
|
24
|
+
onExitFocusMode: () => void;
|
|
25
|
+
onSendInput: (data: string) => void;
|
|
26
|
+
helpVisible: boolean;
|
|
27
|
+
onToggleHelp: () => void;
|
|
28
|
+
onCloseHelp: () => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function getStatusIndicator({
|
|
32
|
+
status,
|
|
33
|
+
inverse,
|
|
34
|
+
}: {
|
|
35
|
+
status: TabProcess['status'];
|
|
36
|
+
inverse?: boolean;
|
|
37
|
+
}) {
|
|
38
|
+
switch (status) {
|
|
39
|
+
case 'running':
|
|
40
|
+
return { color: inverse ? 'cyanBright' : 'cyan', icon: '●' };
|
|
41
|
+
case 'done':
|
|
42
|
+
return { color: inverse ? 'greenBright' : 'green', icon: '✓' };
|
|
43
|
+
case 'error':
|
|
44
|
+
return { color: inverse ? 'redBright' : 'red', icon: '✗' };
|
|
45
|
+
case 'stopped':
|
|
46
|
+
return { color: inverse ? 'yellowBright' : 'yellow', icon: '■' };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function TabBar({
|
|
51
|
+
tabs,
|
|
52
|
+
activeIndex,
|
|
53
|
+
focusMode,
|
|
54
|
+
}: {
|
|
55
|
+
tabs: TabProcess[];
|
|
56
|
+
activeIndex: number;
|
|
57
|
+
focusMode: boolean;
|
|
58
|
+
}) {
|
|
59
|
+
return (
|
|
60
|
+
<Box gap={1} flexWrap="wrap">
|
|
61
|
+
{tabs.map((tab, i) => {
|
|
62
|
+
const isActive = i === activeIndex;
|
|
63
|
+
const { color, icon } = getStatusIndicator({
|
|
64
|
+
status: tab.status,
|
|
65
|
+
inverse: isActive,
|
|
66
|
+
});
|
|
67
|
+
return (
|
|
68
|
+
<Box key={tab.id}>
|
|
69
|
+
<Text inverse={isActive} color={color}>
|
|
70
|
+
{' '}
|
|
71
|
+
{icon}{' '}
|
|
72
|
+
</Text>
|
|
73
|
+
<Text inverse={isActive}> {tab.label}</Text>
|
|
74
|
+
<Text inverse={isActive}>
|
|
75
|
+
{' ('}
|
|
76
|
+
{i + 1}
|
|
77
|
+
{') '}
|
|
78
|
+
</Text>
|
|
79
|
+
</Box>
|
|
80
|
+
);
|
|
81
|
+
})}
|
|
82
|
+
{focusMode && (
|
|
83
|
+
<Text backgroundColor="yellow" color="black">
|
|
84
|
+
{' INPUT MODE '}
|
|
85
|
+
</Text>
|
|
86
|
+
)}
|
|
87
|
+
</Box>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function OutputView({
|
|
92
|
+
lines,
|
|
93
|
+
scrollOffset,
|
|
94
|
+
viewHeight,
|
|
95
|
+
canScrollUp,
|
|
96
|
+
canScrollDown,
|
|
97
|
+
}: {
|
|
98
|
+
lines: string[];
|
|
99
|
+
scrollOffset: number;
|
|
100
|
+
viewHeight: number;
|
|
101
|
+
canScrollUp: boolean;
|
|
102
|
+
canScrollDown: boolean;
|
|
103
|
+
}) {
|
|
104
|
+
const visibleLines = lines.slice(scrollOffset, scrollOffset + viewHeight);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<Box flexDirection="column" height={viewHeight} overflow="hidden">
|
|
108
|
+
{visibleLines.map((line, i) => {
|
|
109
|
+
// Show scroll indicator on first two lines
|
|
110
|
+
let prefix = ' ';
|
|
111
|
+
if (i === 0 && canScrollUp) prefix = '↑ ';
|
|
112
|
+
else if (i === 1 && canScrollDown) prefix = '↓ ';
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<Text key={i} wrap="truncate" dimColor={i < 2 && prefix !== ' '}>
|
|
116
|
+
<Text dimColor>{prefix}</Text>
|
|
117
|
+
<Text>{line || ' '}</Text>
|
|
118
|
+
</Text>
|
|
119
|
+
);
|
|
120
|
+
})}
|
|
121
|
+
{/* Fill remaining space with empty lines */}
|
|
122
|
+
{Array.from({
|
|
123
|
+
length: Math.max(0, viewHeight - visibleLines.length),
|
|
124
|
+
}).map((_, i) => {
|
|
125
|
+
const lineIndex = visibleLines.length + i;
|
|
126
|
+
let prefix = ' ';
|
|
127
|
+
if (lineIndex === 0 && canScrollUp) prefix = '↑ ';
|
|
128
|
+
else if (lineIndex === 1 && canScrollDown) prefix = '↓ ';
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<Text key={`empty-${i}`}>
|
|
132
|
+
<Text dimColor>{prefix}</Text>
|
|
133
|
+
<Text> </Text>
|
|
134
|
+
</Text>
|
|
135
|
+
);
|
|
136
|
+
})}
|
|
137
|
+
</Box>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function StatusBar({
|
|
142
|
+
tabCount,
|
|
143
|
+
quitConfirmPending,
|
|
144
|
+
focusMode,
|
|
145
|
+
showHelpHint,
|
|
146
|
+
}: {
|
|
147
|
+
tabCount: number;
|
|
148
|
+
quitConfirmPending: boolean;
|
|
149
|
+
focusMode: boolean;
|
|
150
|
+
showHelpHint: boolean;
|
|
151
|
+
}) {
|
|
152
|
+
if (quitConfirmPending) {
|
|
153
|
+
return (
|
|
154
|
+
<Box>
|
|
155
|
+
<Text backgroundColor="yellow" color="black">
|
|
156
|
+
{' '}
|
|
157
|
+
Press q again to quit, any other key to cancel{' '}
|
|
158
|
+
</Text>
|
|
159
|
+
</Box>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (focusMode) {
|
|
164
|
+
return (
|
|
165
|
+
<Box>
|
|
166
|
+
<Text backgroundColor="cyan" color="black">
|
|
167
|
+
{' '}
|
|
168
|
+
INPUT MODE{' '}
|
|
169
|
+
</Text>
|
|
170
|
+
<Text dimColor> Press Esc to exit input mode</Text>
|
|
171
|
+
</Box>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<Box>
|
|
177
|
+
<Text dimColor>
|
|
178
|
+
[↑↓] scroll | [Tab/1-{tabCount}] switch | [i]nput | [r]estart | [k]ill | [q]uit
|
|
179
|
+
{showHelpHint && ' | Press ? for help'}
|
|
180
|
+
</Text>
|
|
181
|
+
</Box>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Normalize Ink's useInput key event to the shared NormalizedKeyEvent format.
|
|
187
|
+
*/
|
|
188
|
+
function normalizeInkKeyEvent(
|
|
189
|
+
input: string,
|
|
190
|
+
key: {
|
|
191
|
+
upArrow: boolean;
|
|
192
|
+
downArrow: boolean;
|
|
193
|
+
leftArrow: boolean;
|
|
194
|
+
rightArrow: boolean;
|
|
195
|
+
return: boolean;
|
|
196
|
+
escape: boolean;
|
|
197
|
+
ctrl: boolean;
|
|
198
|
+
shift: boolean;
|
|
199
|
+
meta: boolean;
|
|
200
|
+
tab: boolean;
|
|
201
|
+
backspace: boolean;
|
|
202
|
+
delete: boolean;
|
|
203
|
+
pageUp: boolean;
|
|
204
|
+
pageDown: boolean;
|
|
205
|
+
}
|
|
206
|
+
): NormalizedKeyEvent {
|
|
207
|
+
// Map Ink key properties to normalized key names
|
|
208
|
+
let name: string | undefined;
|
|
209
|
+
if (key.escape) name = 'escape';
|
|
210
|
+
else if (key.return) name = 'return';
|
|
211
|
+
else if (key.tab) name = 'tab';
|
|
212
|
+
else if (key.backspace) name = 'backspace';
|
|
213
|
+
else if (key.delete) name = 'delete';
|
|
214
|
+
else if (key.upArrow) name = 'up';
|
|
215
|
+
else if (key.downArrow) name = 'down';
|
|
216
|
+
else if (key.leftArrow) name = 'left';
|
|
217
|
+
else if (key.rightArrow) name = 'right';
|
|
218
|
+
else if (key.pageUp) name = 'pageup';
|
|
219
|
+
else if (key.pageDown) name = 'pagedown';
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
char: input || undefined,
|
|
223
|
+
name,
|
|
224
|
+
ctrl: key.ctrl,
|
|
225
|
+
shift: key.shift,
|
|
226
|
+
meta: key.meta,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function TabbedView({
|
|
231
|
+
tabs,
|
|
232
|
+
activeIndex,
|
|
233
|
+
onActiveIndexChange,
|
|
234
|
+
onQuit,
|
|
235
|
+
onQuitRequest,
|
|
236
|
+
onRestart,
|
|
237
|
+
onKill,
|
|
238
|
+
quitConfirmPending,
|
|
239
|
+
focusMode,
|
|
240
|
+
onEnterFocusMode,
|
|
241
|
+
onExitFocusMode,
|
|
242
|
+
onSendInput,
|
|
243
|
+
helpVisible,
|
|
244
|
+
onToggleHelp,
|
|
245
|
+
onCloseHelp,
|
|
246
|
+
}: TabbedViewProps) {
|
|
247
|
+
const { stdout } = useStdout();
|
|
248
|
+
|
|
249
|
+
// Calculate view height based on terminal size
|
|
250
|
+
// Reserve space for: padding (2) + tab bar (1) + border (2) + status bar (1) = 6
|
|
251
|
+
const terminalHeight = stdout?.rows ?? 24;
|
|
252
|
+
const viewHeight = Math.max(5, terminalHeight - 6);
|
|
253
|
+
|
|
254
|
+
// Use shared tabs state hook
|
|
255
|
+
const {
|
|
256
|
+
showHelpHint,
|
|
257
|
+
activeScrollOffset,
|
|
258
|
+
canScrollUp,
|
|
259
|
+
canScrollDown,
|
|
260
|
+
scrollBy,
|
|
261
|
+
switchTab,
|
|
262
|
+
nextTab,
|
|
263
|
+
prevTab,
|
|
264
|
+
} = useTabsState({
|
|
265
|
+
tabs,
|
|
266
|
+
activeIndex,
|
|
267
|
+
onActiveIndexChange,
|
|
268
|
+
viewHeight,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Use shared callback refs hook
|
|
272
|
+
const callbacks: KeyboardCallbacks = {
|
|
273
|
+
onQuit,
|
|
274
|
+
onQuitRequest,
|
|
275
|
+
onRestart,
|
|
276
|
+
onKill,
|
|
277
|
+
onEnterFocusMode,
|
|
278
|
+
onExitFocusMode,
|
|
279
|
+
onSendInput,
|
|
280
|
+
onToggleHelp,
|
|
281
|
+
onCloseHelp,
|
|
282
|
+
};
|
|
283
|
+
const callbackRefs = useKeyboardCallbackRefs(callbacks);
|
|
284
|
+
|
|
285
|
+
// Keyboard handling via useInput - always active
|
|
286
|
+
useInput((input, key) => {
|
|
287
|
+
const normalizedEvent = normalizeInkKeyEvent(input, key);
|
|
288
|
+
const action = processKeyEvent(
|
|
289
|
+
normalizedEvent,
|
|
290
|
+
{
|
|
291
|
+
helpVisible,
|
|
292
|
+
focusMode,
|
|
293
|
+
quitConfirmPending,
|
|
294
|
+
activeIndex,
|
|
295
|
+
tabCount: tabs.length,
|
|
296
|
+
},
|
|
297
|
+
viewHeight
|
|
298
|
+
);
|
|
299
|
+
executeKeyboardAction(action, callbackRefs, scrollBy, switchTab, nextTab, prevTab);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const activeTab = tabs[activeIndex];
|
|
303
|
+
if (!activeTab) {
|
|
304
|
+
return <Text>No tabs available</Text>;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<Box flexDirection="column" padding={1}>
|
|
309
|
+
<TabBar tabs={tabs} activeIndex={activeIndex} focusMode={focusMode} />
|
|
310
|
+
<Box
|
|
311
|
+
borderLeft={false}
|
|
312
|
+
borderRight={false}
|
|
313
|
+
borderStyle="single"
|
|
314
|
+
borderColor="gray"
|
|
315
|
+
paddingX={1}
|
|
316
|
+
>
|
|
317
|
+
<OutputView
|
|
318
|
+
lines={activeTab.output}
|
|
319
|
+
scrollOffset={activeScrollOffset}
|
|
320
|
+
viewHeight={viewHeight}
|
|
321
|
+
canScrollUp={canScrollUp}
|
|
322
|
+
canScrollDown={canScrollDown}
|
|
323
|
+
/>
|
|
324
|
+
</Box>
|
|
325
|
+
<StatusBar
|
|
326
|
+
tabCount={tabs.length}
|
|
327
|
+
quitConfirmPending={quitConfirmPending}
|
|
328
|
+
focusMode={focusMode}
|
|
329
|
+
showHelpHint={showHelpHint}
|
|
330
|
+
/>
|
|
331
|
+
|
|
332
|
+
{/* Help overlay - rendered on top */}
|
|
333
|
+
{helpVisible && (
|
|
334
|
+
<Box
|
|
335
|
+
position="absolute"
|
|
336
|
+
flexDirection="column"
|
|
337
|
+
alignItems="center"
|
|
338
|
+
justifyContent="center"
|
|
339
|
+
width="100%"
|
|
340
|
+
height="100%"
|
|
341
|
+
>
|
|
342
|
+
<HelpOverlay onClose={onCloseHelp} />
|
|
343
|
+
</Box>
|
|
344
|
+
)}
|
|
345
|
+
</Box>
|
|
346
|
+
);
|
|
347
|
+
}
|
package/src/tabs-app.tsx
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
3
|
+
import { TabbedView } from './tabbed-view.js';
|
|
4
|
+
import {
|
|
5
|
+
OutputBuffer,
|
|
6
|
+
MAX_OUTPUT_LINES,
|
|
7
|
+
MAX_LINE_LENGTH,
|
|
8
|
+
BUFFER_WARNING_THRESHOLD,
|
|
9
|
+
} from '@pokit/tabs-core';
|
|
10
|
+
import type { TabProcess } from './types.js';
|
|
11
|
+
import type { TabSpec, TabsOptions } from '@pokit/core';
|
|
12
|
+
|
|
13
|
+
const OUTPUT_BATCH_MS = 16;
|
|
14
|
+
|
|
15
|
+
type TabsAppProps = {
|
|
16
|
+
items: TabSpec[];
|
|
17
|
+
options: TabsOptions;
|
|
18
|
+
onExit: (code: number) => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Temporary buffer for batching incoming output before flushing to state */
|
|
22
|
+
type BatchBuffer = {
|
|
23
|
+
lines: string[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function TabsApp({ items, options, onExit }: TabsAppProps) {
|
|
27
|
+
// Ring buffers for storing all output per tab (with O(1) operations)
|
|
28
|
+
const ringBuffersRef = useRef<Map<string, OutputBuffer>>(new Map());
|
|
29
|
+
|
|
30
|
+
// Initialize ring buffers for each tab
|
|
31
|
+
const getOrCreateRingBuffer = useCallback((tabId: string): OutputBuffer => {
|
|
32
|
+
let buffer = ringBuffersRef.current.get(tabId);
|
|
33
|
+
if (!buffer) {
|
|
34
|
+
buffer = new OutputBuffer({
|
|
35
|
+
maxLines: MAX_OUTPUT_LINES,
|
|
36
|
+
maxLineLength: MAX_LINE_LENGTH,
|
|
37
|
+
warnAtPercentage: BUFFER_WARNING_THRESHOLD,
|
|
38
|
+
tabId,
|
|
39
|
+
// Optional: could add onPressure callback here for UI feedback
|
|
40
|
+
});
|
|
41
|
+
ringBuffersRef.current.set(tabId, buffer);
|
|
42
|
+
}
|
|
43
|
+
return buffer;
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const [tabs, setTabs] = useState<TabProcess[]>(() =>
|
|
47
|
+
items.map((item, i) => ({
|
|
48
|
+
id: `tab-${i}`,
|
|
49
|
+
label: item.label,
|
|
50
|
+
exec: item.exec,
|
|
51
|
+
output: [],
|
|
52
|
+
status: 'running' as const,
|
|
53
|
+
}))
|
|
54
|
+
);
|
|
55
|
+
const [quitConfirmPending, setQuitConfirmPending] = useState(false);
|
|
56
|
+
const [focusMode, setFocusMode] = useState(false);
|
|
57
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
58
|
+
const [helpVisible, setHelpVisible] = useState(false);
|
|
59
|
+
const processesRef = useRef<(ChildProcess | null)[]>([]);
|
|
60
|
+
const batchBuffersRef = useRef<Map<number, BatchBuffer>>(new Map());
|
|
61
|
+
const flushScheduledRef = useRef(false);
|
|
62
|
+
|
|
63
|
+
const flushOutput = useCallback(() => {
|
|
64
|
+
flushScheduledRef.current = false;
|
|
65
|
+
|
|
66
|
+
const buffersSnapshot = batchBuffersRef.current;
|
|
67
|
+
batchBuffersRef.current = new Map();
|
|
68
|
+
|
|
69
|
+
if (buffersSnapshot.size === 0) return;
|
|
70
|
+
|
|
71
|
+
const updates: Array<{ index: number; lines: string[] }> = [];
|
|
72
|
+
for (const [index, buffer] of buffersSnapshot.entries()) {
|
|
73
|
+
if (buffer.lines.length > 0) {
|
|
74
|
+
updates.push({ index, lines: buffer.lines });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (updates.length === 0) return;
|
|
79
|
+
|
|
80
|
+
setTabs((prev) => {
|
|
81
|
+
const next = [...prev];
|
|
82
|
+
for (const { index, lines } of updates) {
|
|
83
|
+
const tab = next[index];
|
|
84
|
+
if (!tab) continue;
|
|
85
|
+
|
|
86
|
+
// Push to ring buffer and get output with dropped indicator
|
|
87
|
+
const ringBuffer = getOrCreateRingBuffer(tab.id);
|
|
88
|
+
ringBuffer.push(...lines);
|
|
89
|
+
next[index] = { ...tab, output: ringBuffer.toArray() };
|
|
90
|
+
}
|
|
91
|
+
return next;
|
|
92
|
+
});
|
|
93
|
+
}, [getOrCreateRingBuffer]);
|
|
94
|
+
|
|
95
|
+
const scheduleFlush = useCallback(() => {
|
|
96
|
+
if (flushScheduledRef.current) return;
|
|
97
|
+
flushScheduledRef.current = true;
|
|
98
|
+
setTimeout(flushOutput, OUTPUT_BATCH_MS);
|
|
99
|
+
}, [flushOutput]);
|
|
100
|
+
|
|
101
|
+
const appendOutput = useCallback(
|
|
102
|
+
(index: number, data: Buffer) => {
|
|
103
|
+
const text = data.toString('utf-8');
|
|
104
|
+
const lines = text.split(/\r?\n/).filter((line, idx, arr) => {
|
|
105
|
+
return line || idx < arr.length - 1;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (lines.length === 0) return;
|
|
109
|
+
|
|
110
|
+
let buffer = batchBuffersRef.current.get(index);
|
|
111
|
+
if (!buffer) {
|
|
112
|
+
buffer = { lines: [] };
|
|
113
|
+
batchBuffersRef.current.set(index, buffer);
|
|
114
|
+
}
|
|
115
|
+
buffer.lines.push(...lines);
|
|
116
|
+
|
|
117
|
+
scheduleFlush();
|
|
118
|
+
},
|
|
119
|
+
[scheduleFlush]
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const spawnProcess = useCallback(
|
|
123
|
+
(index: number): ChildProcess | null => {
|
|
124
|
+
const item = items[index];
|
|
125
|
+
if (!item) return null;
|
|
126
|
+
|
|
127
|
+
// Use 'pipe' for stdin so we can send input in focus mode
|
|
128
|
+
const proc = spawn('sh', ['-c', item.exec], {
|
|
129
|
+
cwd: options.cwd,
|
|
130
|
+
env: {
|
|
131
|
+
...options.env,
|
|
132
|
+
FORCE_COLOR: '1',
|
|
133
|
+
} as NodeJS.ProcessEnv,
|
|
134
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const handleData = (data: Buffer) => appendOutput(index, data);
|
|
138
|
+
|
|
139
|
+
proc.stdout?.on('data', handleData);
|
|
140
|
+
proc.stderr?.on('data', handleData);
|
|
141
|
+
|
|
142
|
+
proc.on('close', (code) => {
|
|
143
|
+
flushOutput();
|
|
144
|
+
setTabs((prev) => {
|
|
145
|
+
const next = [...prev];
|
|
146
|
+
const tab = next[index];
|
|
147
|
+
if (!tab) return prev;
|
|
148
|
+
next[index] = {
|
|
149
|
+
...tab,
|
|
150
|
+
status: code === 0 ? 'done' : 'error',
|
|
151
|
+
exitCode: code ?? undefined,
|
|
152
|
+
};
|
|
153
|
+
return next;
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
proc.on('error', (err) => {
|
|
158
|
+
setTabs((prev) => {
|
|
159
|
+
const next = [...prev];
|
|
160
|
+
const tab = next[index];
|
|
161
|
+
if (!tab) return prev;
|
|
162
|
+
next[index] = {
|
|
163
|
+
...tab,
|
|
164
|
+
status: 'error',
|
|
165
|
+
output: [...tab.output, `Error: ${err.message}`],
|
|
166
|
+
};
|
|
167
|
+
return next;
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return proc;
|
|
172
|
+
},
|
|
173
|
+
[items, options.cwd, options.env, appendOutput, flushOutput]
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
const procs = items.map((_, i) => spawnProcess(i));
|
|
178
|
+
processesRef.current = procs;
|
|
179
|
+
|
|
180
|
+
return () => {
|
|
181
|
+
for (const proc of processesRef.current) {
|
|
182
|
+
if (proc && !proc.killed) {
|
|
183
|
+
proc.kill('SIGTERM');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}, [items, spawnProcess]);
|
|
188
|
+
|
|
189
|
+
const killAll = useCallback(() => {
|
|
190
|
+
for (const proc of processesRef.current) {
|
|
191
|
+
if (proc && !proc.killed) {
|
|
192
|
+
proc.kill('SIGTERM');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}, []);
|
|
196
|
+
|
|
197
|
+
const handleRestart = useCallback(
|
|
198
|
+
(index: number) => {
|
|
199
|
+
const existingProc = processesRef.current[index];
|
|
200
|
+
if (existingProc && !existingProc.killed) {
|
|
201
|
+
existingProc.kill('SIGTERM');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Clear both batch buffer and ring buffer
|
|
205
|
+
batchBuffersRef.current.delete(index);
|
|
206
|
+
const tabId = `tab-${index}`;
|
|
207
|
+
ringBuffersRef.current.get(tabId)?.clear();
|
|
208
|
+
|
|
209
|
+
setTabs((prev) => {
|
|
210
|
+
const next = [...prev];
|
|
211
|
+
const tab = next[index];
|
|
212
|
+
if (!tab) return prev;
|
|
213
|
+
next[index] = {
|
|
214
|
+
...tab,
|
|
215
|
+
output: ['Restarting...', ''],
|
|
216
|
+
status: 'running',
|
|
217
|
+
exitCode: undefined,
|
|
218
|
+
};
|
|
219
|
+
return next;
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
setTimeout(() => {
|
|
223
|
+
const newProc = spawnProcess(index);
|
|
224
|
+
processesRef.current[index] = newProc;
|
|
225
|
+
}, 100);
|
|
226
|
+
},
|
|
227
|
+
[spawnProcess]
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const handleKill = useCallback((index: number) => {
|
|
231
|
+
const proc = processesRef.current[index];
|
|
232
|
+
if (proc && !proc.killed) {
|
|
233
|
+
proc.kill('SIGTERM');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
setTabs((prev) => {
|
|
237
|
+
const next = [...prev];
|
|
238
|
+
const tab = next[index];
|
|
239
|
+
if (!tab) return prev;
|
|
240
|
+
if (tab.status !== 'running') return prev;
|
|
241
|
+
next[index] = {
|
|
242
|
+
...tab,
|
|
243
|
+
status: 'stopped',
|
|
244
|
+
output: [...tab.output, '', 'Stopped'],
|
|
245
|
+
};
|
|
246
|
+
return next;
|
|
247
|
+
});
|
|
248
|
+
}, []);
|
|
249
|
+
|
|
250
|
+
const handleQuitRequest = useCallback(() => {
|
|
251
|
+
setQuitConfirmPending((prev) => !prev);
|
|
252
|
+
}, []);
|
|
253
|
+
|
|
254
|
+
const handleQuit = useCallback(() => {
|
|
255
|
+
killAll();
|
|
256
|
+
onExit(0);
|
|
257
|
+
}, [killAll, onExit]);
|
|
258
|
+
|
|
259
|
+
const handleEnterFocusMode = useCallback(() => {
|
|
260
|
+
const proc = processesRef.current[activeIndex];
|
|
261
|
+
if (proc && !proc.killed && proc.stdin) {
|
|
262
|
+
setFocusMode(true);
|
|
263
|
+
}
|
|
264
|
+
}, [activeIndex]);
|
|
265
|
+
|
|
266
|
+
const handleExitFocusMode = useCallback(() => {
|
|
267
|
+
setFocusMode(false);
|
|
268
|
+
}, []);
|
|
269
|
+
|
|
270
|
+
const handleSendInput = useCallback(
|
|
271
|
+
(data: string) => {
|
|
272
|
+
const proc = processesRef.current[activeIndex];
|
|
273
|
+
if (proc && !proc.killed && proc.stdin) {
|
|
274
|
+
proc.stdin.write(data);
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
[activeIndex]
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const handleActiveIndexChange = useCallback((index: number) => {
|
|
281
|
+
setActiveIndex(index);
|
|
282
|
+
// Exit focus mode when switching tabs
|
|
283
|
+
setFocusMode(false);
|
|
284
|
+
}, []);
|
|
285
|
+
|
|
286
|
+
const handleToggleHelp = useCallback(() => {
|
|
287
|
+
setHelpVisible((prev) => !prev);
|
|
288
|
+
}, []);
|
|
289
|
+
|
|
290
|
+
const handleCloseHelp = useCallback(() => {
|
|
291
|
+
setHelpVisible(false);
|
|
292
|
+
}, []);
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<TabbedView
|
|
296
|
+
tabs={tabs}
|
|
297
|
+
activeIndex={activeIndex}
|
|
298
|
+
onActiveIndexChange={handleActiveIndexChange}
|
|
299
|
+
onQuit={handleQuit}
|
|
300
|
+
onQuitRequest={handleQuitRequest}
|
|
301
|
+
onRestart={handleRestart}
|
|
302
|
+
onKill={handleKill}
|
|
303
|
+
quitConfirmPending={quitConfirmPending}
|
|
304
|
+
focusMode={focusMode}
|
|
305
|
+
onEnterFocusMode={handleEnterFocusMode}
|
|
306
|
+
onExitFocusMode={handleExitFocusMode}
|
|
307
|
+
onSendInput={handleSendInput}
|
|
308
|
+
helpVisible={helpVisible}
|
|
309
|
+
onToggleHelp={handleToggleHelp}
|
|
310
|
+
onCloseHelp={handleCloseHelp}
|
|
311
|
+
/>
|
|
312
|
+
);
|
|
313
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for tabs-ink
|
|
3
|
+
*
|
|
4
|
+
* Re-exports shared types from @pokit/tabs-core and defines implementation-specific types.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChildProcess } from 'node:child_process';
|
|
8
|
+
import type { TabProcess as BaseTabProcess } from '@pokit/tabs-core';
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Re-exports from @pokit/tabs-core
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
export type { TabStatus, ActivityNode, GroupNode, EventDrivenState } from '@pokit/tabs-core';
|
|
15
|
+
|
|
16
|
+
export { MAX_OUTPUT_LINES } from '@pokit/tabs-core';
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Implementation-specific types for tabs-ink
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extended TabProcess with ChildProcess reference for process management.
|
|
24
|
+
* This extends the base TabProcess from tabs-core with Ink-specific fields.
|
|
25
|
+
*/
|
|
26
|
+
export type TabProcess = BaseTabProcess & {
|
|
27
|
+
/** Reference to the spawned child process (Ink-specific) */
|
|
28
|
+
process?: ChildProcess;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Props for the TabbedView component (UI-specific)
|
|
33
|
+
*/
|
|
34
|
+
export type TabbedViewProps = {
|
|
35
|
+
tabs: TabProcess[];
|
|
36
|
+
onQuit: () => void;
|
|
37
|
+
onQuitRequest: () => void;
|
|
38
|
+
quitConfirmPending: boolean;
|
|
39
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Hook for EventBus
|
|
3
|
+
*
|
|
4
|
+
* Provides a hook to subscribe to EventBus events and manage state.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
8
|
+
import type { EventBus, CLIEvent } from '@pokit/core';
|
|
9
|
+
import type { EventDrivenState } from '@pokit/tabs-core';
|
|
10
|
+
import { createInitialState, reducer } from '@pokit/tabs-core';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hook to subscribe to EventBus and manage state
|
|
14
|
+
*/
|
|
15
|
+
export function useEventBus(bus: EventBus): EventDrivenState {
|
|
16
|
+
const [state, setState] = useState<EventDrivenState>(createInitialState);
|
|
17
|
+
|
|
18
|
+
const handleEvent = useCallback((event: CLIEvent) => {
|
|
19
|
+
setState((prevState) => reducer(prevState, event));
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const unsubscribe = bus.on(handleEvent);
|
|
24
|
+
return unsubscribe;
|
|
25
|
+
}, [bus, handleEvent]);
|
|
26
|
+
|
|
27
|
+
return state;
|
|
28
|
+
}
|