@pokit/tabs-opentui 0.0.1 → 0.0.2
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 +244 -0
- package/src/error-boundary.tsx +95 -0
- package/src/event-driven-app.tsx +237 -0
- package/src/help-overlay.tsx +49 -0
- package/src/tabbed-view.tsx +306 -0
- package/src/tabs-app.tsx +391 -0
- package/src/use-event-bus.ts +28 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pokit/tabs-opentui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "OpenTUI-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"
|
|
@@ -53,12 +53,12 @@
|
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/bun": "latest",
|
|
55
55
|
"@types/react": "^19.2.0",
|
|
56
|
-
"@pokit/core": "0.0.
|
|
57
|
-
"@pokit/tabs-core": "0.0.
|
|
56
|
+
"@pokit/core": "0.0.2",
|
|
57
|
+
"@pokit/tabs-core": "0.0.2"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@pokit/core": "0.0.
|
|
61
|
-
"@pokit/tabs-core": "0.0.
|
|
60
|
+
"@pokit/core": "0.0.2",
|
|
61
|
+
"@pokit/tabs-core": "0.0.2"
|
|
62
62
|
},
|
|
63
63
|
"engines": {
|
|
64
64
|
"bun": ">=1.0.0"
|
package/src/adapter.tsx
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenTUI-based Tabs Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the TabsAdapter interface using OpenTUI (React for CLI).
|
|
5
|
+
* Also provides an event-driven adapter that renders based on EventBus events.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as React from 'react';
|
|
9
|
+
import { createCliRenderer } from '@opentui/core';
|
|
10
|
+
import { createRoot } from '@opentui/react';
|
|
11
|
+
import type { TabsAdapter, TabSpec, TabsOptions, EventBus } from '@pokit/core';
|
|
12
|
+
import { TabsApp } from './tabs-app.js';
|
|
13
|
+
import { EventDrivenApp } from './event-driven-app.js';
|
|
14
|
+
import { TabsErrorBoundary, restoreTerminal } from './error-boundary.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a tabs adapter using OpenTUI
|
|
18
|
+
*/
|
|
19
|
+
export function createTabsAdapter(): TabsAdapter {
|
|
20
|
+
return {
|
|
21
|
+
async run(items: TabSpec[], options: TabsOptions): Promise<void> {
|
|
22
|
+
if (!process.stdout.isTTY) {
|
|
23
|
+
throw new Error('Tabbed view requires stdout to be a TTY');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!process.stdin.isTTY) {
|
|
27
|
+
throw new Error('Tabbed view requires stdin to be a TTY for keyboard input');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (items.length === 0) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Ensure stdin is not paused - previous CLI operations (prompts, spinners)
|
|
35
|
+
// may have left it in a paused state
|
|
36
|
+
if (process.stdin.isPaused()) {
|
|
37
|
+
process.stdin.resume();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Enable raw mode before OpenTUI starts - this ensures stdin is ready
|
|
41
|
+
// to receive escape sequence responses for capability detection
|
|
42
|
+
process.stdin.setRawMode(true);
|
|
43
|
+
|
|
44
|
+
// Let OpenTUI handle alternate screen via its config
|
|
45
|
+
const renderer = await createCliRenderer({
|
|
46
|
+
exitOnCtrlC: false,
|
|
47
|
+
useAlternateScreen: true,
|
|
48
|
+
useMouse: true, // Enable mouse for scroll wheel support
|
|
49
|
+
useKittyKeyboard: {}, // Enable Kitty keyboard protocol for better key handling
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Disable stdout interception to prevent output mangling with scrolling
|
|
53
|
+
renderer.disableStdoutInterception();
|
|
54
|
+
|
|
55
|
+
const root = createRoot(renderer);
|
|
56
|
+
|
|
57
|
+
// Start the render loop explicitly
|
|
58
|
+
renderer.start();
|
|
59
|
+
|
|
60
|
+
return new Promise<void>((resolve) => {
|
|
61
|
+
let resolved = false;
|
|
62
|
+
|
|
63
|
+
// Cleanup function to restore terminal and resolve
|
|
64
|
+
const cleanup = () => {
|
|
65
|
+
if (resolved) return;
|
|
66
|
+
resolved = true;
|
|
67
|
+
|
|
68
|
+
// Remove signal handlers
|
|
69
|
+
process.removeListener('SIGTERM', handleSignal);
|
|
70
|
+
process.removeListener('SIGQUIT', handleSignal);
|
|
71
|
+
process.removeListener('uncaughtException', handleUncaughtException);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
root.unmount();
|
|
75
|
+
renderer.destroy();
|
|
76
|
+
} catch {
|
|
77
|
+
// Ignore errors during cleanup
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
restoreTerminal();
|
|
81
|
+
resolve();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Signal handler for graceful shutdown
|
|
85
|
+
const handleSignal = () => {
|
|
86
|
+
cleanup();
|
|
87
|
+
process.exit(0);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Handle uncaught exceptions
|
|
91
|
+
const handleUncaughtException = (error: Error) => {
|
|
92
|
+
restoreTerminal();
|
|
93
|
+
console.error('\n[TabsUI] Uncaught exception:', error);
|
|
94
|
+
cleanup();
|
|
95
|
+
process.exit(1);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Register signal handlers
|
|
99
|
+
process.on('SIGTERM', handleSignal);
|
|
100
|
+
process.on('SIGQUIT', handleSignal);
|
|
101
|
+
process.on('uncaughtException', handleUncaughtException);
|
|
102
|
+
|
|
103
|
+
// Handle fatal errors from error boundary
|
|
104
|
+
const handleFatalError = () => {
|
|
105
|
+
cleanup();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const handleExit = () => {
|
|
109
|
+
cleanup();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Use React.createElement to bypass OpenTUI's JSX type constraints for class components
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
114
|
+
root.render(
|
|
115
|
+
React.createElement(
|
|
116
|
+
TabsErrorBoundary,
|
|
117
|
+
{ onFatalError: handleFatalError },
|
|
118
|
+
React.createElement(TabsApp, { items, options, onExit: handleExit })
|
|
119
|
+
) as any
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type EventAdapterOptions = {
|
|
127
|
+
onExit?: (code: number) => void;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create an event-driven adapter that renders based on EventBus events.
|
|
132
|
+
* This adapter builds a state tree from events and renders it using OpenTUI.
|
|
133
|
+
*
|
|
134
|
+
* @param bus - The EventBus to listen to
|
|
135
|
+
* @param options - Options for the adapter
|
|
136
|
+
* @returns Object with unmount function to stop the adapter
|
|
137
|
+
*/
|
|
138
|
+
export function createEventAdapter(
|
|
139
|
+
bus: EventBus,
|
|
140
|
+
options: EventAdapterOptions = {}
|
|
141
|
+
): { unmount: () => void } {
|
|
142
|
+
if (!process.stdout.isTTY) {
|
|
143
|
+
throw new Error('Event-driven tabs view requires stdout to be a TTY');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!process.stdin.isTTY) {
|
|
147
|
+
throw new Error('Event-driven tabs view requires stdin to be a TTY for keyboard input');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let root: ReturnType<typeof createRoot> | null = null;
|
|
151
|
+
let renderer: Awaited<ReturnType<typeof createCliRenderer>> | null = null;
|
|
152
|
+
let isCleanedUp = false;
|
|
153
|
+
|
|
154
|
+
// Cleanup function to restore terminal
|
|
155
|
+
const cleanup = () => {
|
|
156
|
+
if (isCleanedUp) return;
|
|
157
|
+
isCleanedUp = true;
|
|
158
|
+
|
|
159
|
+
// Remove signal handlers
|
|
160
|
+
process.removeListener('SIGTERM', handleSignal);
|
|
161
|
+
process.removeListener('SIGQUIT', handleSignal);
|
|
162
|
+
process.removeListener('uncaughtException', handleUncaughtException);
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
root?.unmount();
|
|
166
|
+
renderer?.destroy();
|
|
167
|
+
} catch {
|
|
168
|
+
// Ignore errors during cleanup
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
restoreTerminal();
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Signal handler for graceful shutdown
|
|
175
|
+
const handleSignal = () => {
|
|
176
|
+
cleanup();
|
|
177
|
+
process.exit(0);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Handle uncaught exceptions
|
|
181
|
+
const handleUncaughtException = (error: Error) => {
|
|
182
|
+
restoreTerminal();
|
|
183
|
+
console.error('\n[TabsUI] Uncaught exception:', error);
|
|
184
|
+
cleanup();
|
|
185
|
+
process.exit(1);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Register signal handlers
|
|
189
|
+
process.on('SIGTERM', handleSignal);
|
|
190
|
+
process.on('SIGQUIT', handleSignal);
|
|
191
|
+
process.on('uncaughtException', handleUncaughtException);
|
|
192
|
+
|
|
193
|
+
const init = async () => {
|
|
194
|
+
// Let OpenTUI handle alternate screen and raw mode via its config
|
|
195
|
+
renderer = await createCliRenderer({
|
|
196
|
+
exitOnCtrlC: false,
|
|
197
|
+
useAlternateScreen: true,
|
|
198
|
+
useMouse: false,
|
|
199
|
+
useKittyKeyboard: {}, // Enable Kitty keyboard protocol for better key handling
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Check if cleanup was requested during async init
|
|
203
|
+
if (isCleanedUp) {
|
|
204
|
+
renderer.destroy();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Disable stdout interception to prevent output mangling with scrolling
|
|
209
|
+
renderer.disableStdoutInterception();
|
|
210
|
+
root = createRoot(renderer);
|
|
211
|
+
|
|
212
|
+
// Start the render loop explicitly
|
|
213
|
+
renderer.start();
|
|
214
|
+
|
|
215
|
+
const handleExit = (code: number) => {
|
|
216
|
+
cleanup();
|
|
217
|
+
options.onExit?.(code);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// Handle fatal errors from error boundary
|
|
221
|
+
const handleFatalError = () => {
|
|
222
|
+
cleanup();
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Use React.createElement to bypass OpenTUI's JSX type constraints for class components
|
|
226
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
227
|
+
root.render(
|
|
228
|
+
React.createElement(
|
|
229
|
+
TabsErrorBoundary,
|
|
230
|
+
{ onFatalError: handleFatalError },
|
|
231
|
+
React.createElement(EventDrivenApp, { bus, onExit: handleExit })
|
|
232
|
+
) as any
|
|
233
|
+
);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
init().catch((error) => {
|
|
237
|
+
console.error('Failed to initialize event adapter:', error);
|
|
238
|
+
restoreTerminal();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
unmount: cleanup,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error Boundary for Tabs UI (OpenTUI)
|
|
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 * as React from 'react';
|
|
10
|
+
|
|
11
|
+
export type ErrorBoundaryProps = {
|
|
12
|
+
children?: React.ReactNode;
|
|
13
|
+
onFatalError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type ErrorBoundaryState = {
|
|
17
|
+
hasError: boolean;
|
|
18
|
+
error: Error | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Restore terminal to a clean state.
|
|
23
|
+
* Called on errors and signal handlers to ensure terminal isn't left corrupted.
|
|
24
|
+
*/
|
|
25
|
+
export function restoreTerminal(): void {
|
|
26
|
+
// Exit alternate screen buffer
|
|
27
|
+
process.stdout.write('\x1b[?1049l');
|
|
28
|
+
// Show cursor (in case it was hidden)
|
|
29
|
+
process.stdout.write('\x1b[?25h');
|
|
30
|
+
// Reset all attributes (colors, styles)
|
|
31
|
+
process.stdout.write('\x1b[0m');
|
|
32
|
+
|
|
33
|
+
// Try to restore raw mode if stdin is a TTY
|
|
34
|
+
if (process.stdin.isTTY && process.stdin.isRaw) {
|
|
35
|
+
try {
|
|
36
|
+
process.stdin.setRawMode(false);
|
|
37
|
+
} catch {
|
|
38
|
+
// Ignore errors - stdin may already be closed
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Internal React Error Boundary class that ensures terminal cleanup on crashes.
|
|
45
|
+
* For OpenTUI, we use a simple fallback since OpenTUI components may not
|
|
46
|
+
* render correctly after an error.
|
|
47
|
+
*/
|
|
48
|
+
class ErrorBoundaryClass extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
49
|
+
constructor(props: ErrorBoundaryProps) {
|
|
50
|
+
super(props);
|
|
51
|
+
this.state = { hasError: false, error: null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
55
|
+
return { hasError: true, error };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
|
59
|
+
// Restore terminal state immediately
|
|
60
|
+
restoreTerminal();
|
|
61
|
+
|
|
62
|
+
// Log error details for debugging
|
|
63
|
+
console.error('\n[TabsUI] Fatal error caught by error boundary:');
|
|
64
|
+
console.error(error);
|
|
65
|
+
if (errorInfo.componentStack) {
|
|
66
|
+
console.error('\nComponent stack:', errorInfo.componentStack);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Notify parent if callback provided
|
|
70
|
+
this.props.onFatalError?.(error, errorInfo);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
render(): React.ReactNode {
|
|
74
|
+
if (this.state.hasError) {
|
|
75
|
+
// For OpenTUI, render a simple box with the error message
|
|
76
|
+
// The terminal should already be restored at this point
|
|
77
|
+
return (
|
|
78
|
+
<box flexDirection="column" padding={1}>
|
|
79
|
+
<text fg="#FF0000">TabsUI encountered a fatal error</text>
|
|
80
|
+
<text fg="#888888">{this.state.error?.message ?? 'Unknown error'}</text>
|
|
81
|
+
<text fg="#666666">Press Ctrl+C to exit</text>
|
|
82
|
+
</box>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return this.props.children;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Export the error boundary class.
|
|
92
|
+
* Note: When using in JSX with OpenTUI, you may need to use React.createElement directly
|
|
93
|
+
* due to OpenTUI's JSX type constraints.
|
|
94
|
+
*/
|
|
95
|
+
export const TabsErrorBoundary = ErrorBoundaryClass;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event-Driven CLI App for OpenTUI
|
|
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 { useKeyboard, useTerminalDimensions } from '@opentui/react';
|
|
10
|
+
import type { KeyEvent } from '@opentui/core';
|
|
11
|
+
import type { EventBus } from '@pokit/core';
|
|
12
|
+
import type { ActivityNode } from '@pokit/tabs-core';
|
|
13
|
+
import { findTabsGroup, getTabsGroupActivities } from '@pokit/tabs-core';
|
|
14
|
+
import { useEventBus } from './use-event-bus.js';
|
|
15
|
+
|
|
16
|
+
type EventDrivenAppProps = {
|
|
17
|
+
bus: EventBus;
|
|
18
|
+
onExit: (code: number) => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function getStatusIndicator({
|
|
22
|
+
status,
|
|
23
|
+
isActive,
|
|
24
|
+
}: {
|
|
25
|
+
status: ActivityNode['status'];
|
|
26
|
+
isActive?: boolean;
|
|
27
|
+
}) {
|
|
28
|
+
switch (status) {
|
|
29
|
+
case 'running':
|
|
30
|
+
return { color: isActive ? '#00FFFF' : '#008B8B', icon: '\u25CF' };
|
|
31
|
+
case 'success':
|
|
32
|
+
return { color: isActive ? '#00FF00' : '#008000', icon: '\u2713' };
|
|
33
|
+
case 'failure':
|
|
34
|
+
return { color: isActive ? '#FF0000' : '#8B0000', icon: '\u2717' };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ActivityTabBar({
|
|
39
|
+
activities,
|
|
40
|
+
activeIndex,
|
|
41
|
+
}: {
|
|
42
|
+
activities: ActivityNode[];
|
|
43
|
+
activeIndex: number;
|
|
44
|
+
}) {
|
|
45
|
+
return (
|
|
46
|
+
<box flexDirection="row" gap={1} flexWrap="wrap">
|
|
47
|
+
{activities.map((activity, i) => {
|
|
48
|
+
const isActive = i === activeIndex;
|
|
49
|
+
const { color, icon } = getStatusIndicator({
|
|
50
|
+
status: activity.status,
|
|
51
|
+
isActive,
|
|
52
|
+
});
|
|
53
|
+
return (
|
|
54
|
+
<box key={activity.id} flexDirection="row">
|
|
55
|
+
<text fg={color}> {icon} </text>
|
|
56
|
+
<box style={isActive ? { backgroundColor: '#444' } : {}}>
|
|
57
|
+
<text fg={isActive ? '#FFF' : '#888'}>
|
|
58
|
+
{' '}
|
|
59
|
+
{activity.label} ({i + 1}){' '}
|
|
60
|
+
</text>
|
|
61
|
+
</box>
|
|
62
|
+
</box>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</box>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ActivityView({ activity, viewHeight }: { activity: ActivityNode; viewHeight: number }) {
|
|
70
|
+
const logs = activity.logs.slice(-viewHeight);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<box flexDirection="column" height={viewHeight} overflow="hidden">
|
|
74
|
+
{activity.message && <text fg="#00FFFF">{activity.message}</text>}
|
|
75
|
+
{activity.progress !== undefined && <text fg="#FFFF00">Progress: {activity.progress}%</text>}
|
|
76
|
+
{logs.map((log, i) => {
|
|
77
|
+
let color: string | undefined;
|
|
78
|
+
switch (log.level) {
|
|
79
|
+
case 'error':
|
|
80
|
+
color = '#FF0000';
|
|
81
|
+
break;
|
|
82
|
+
case 'warn':
|
|
83
|
+
color = '#FFFF00';
|
|
84
|
+
break;
|
|
85
|
+
case 'success':
|
|
86
|
+
color = '#00FF00';
|
|
87
|
+
break;
|
|
88
|
+
case 'info':
|
|
89
|
+
color = '#0000FF';
|
|
90
|
+
break;
|
|
91
|
+
default:
|
|
92
|
+
color = undefined;
|
|
93
|
+
}
|
|
94
|
+
return (
|
|
95
|
+
<text key={i} fg={color}>
|
|
96
|
+
{log.message}
|
|
97
|
+
</text>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
{Array.from({
|
|
101
|
+
length: Math.max(
|
|
102
|
+
0,
|
|
103
|
+
viewHeight -
|
|
104
|
+
logs.length -
|
|
105
|
+
(activity.message ? 1 : 0) -
|
|
106
|
+
(activity.progress !== undefined ? 1 : 0)
|
|
107
|
+
),
|
|
108
|
+
}).map((_, i) => (
|
|
109
|
+
<text key={`empty-${i}`}> </text>
|
|
110
|
+
))}
|
|
111
|
+
</box>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function StatusBar({
|
|
116
|
+
activityCount,
|
|
117
|
+
quitConfirmPending,
|
|
118
|
+
}: {
|
|
119
|
+
activityCount: number;
|
|
120
|
+
quitConfirmPending: boolean;
|
|
121
|
+
}) {
|
|
122
|
+
if (quitConfirmPending) {
|
|
123
|
+
return (
|
|
124
|
+
<box>
|
|
125
|
+
<box style={{ backgroundColor: '#FFFF00' }}>
|
|
126
|
+
<text fg="#000000"> Press q again to quit, any other key to cancel </text>
|
|
127
|
+
</box>
|
|
128
|
+
</box>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<box>
|
|
134
|
+
<text fg="#666666">[Tab/1-{activityCount}] switch | [q]uit</text>
|
|
135
|
+
</box>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function EventDrivenApp({ bus, onExit }: EventDrivenAppProps): React.ReactNode {
|
|
140
|
+
const state = useEventBus(bus);
|
|
141
|
+
const { height: rows } = useTerminalDimensions();
|
|
142
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
143
|
+
const [quitConfirmPending, setQuitConfirmPending] = useState(false);
|
|
144
|
+
|
|
145
|
+
const terminalHeight = rows ?? 24;
|
|
146
|
+
const viewHeight = Math.max(5, terminalHeight - 6);
|
|
147
|
+
|
|
148
|
+
const tabsGroup = findTabsGroup(state);
|
|
149
|
+
const activities = tabsGroup
|
|
150
|
+
? getTabsGroupActivities(state, tabsGroup.id)
|
|
151
|
+
: Array.from(state.activities.values());
|
|
152
|
+
|
|
153
|
+
const activeActivity = activities[activeIndex];
|
|
154
|
+
|
|
155
|
+
const switchTab = useCallback(
|
|
156
|
+
(newIndex: number) => {
|
|
157
|
+
if (newIndex >= 0 && newIndex < activities.length) {
|
|
158
|
+
setActiveIndex(newIndex);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
[activities.length]
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const handleQuitRequest = useCallback(() => {
|
|
165
|
+
setQuitConfirmPending((prev) => !prev);
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
168
|
+
const handleQuit = useCallback(() => {
|
|
169
|
+
onExit(state.exitCode ?? 0);
|
|
170
|
+
}, [onExit, state.exitCode]);
|
|
171
|
+
|
|
172
|
+
useKeyboard((event: KeyEvent) => {
|
|
173
|
+
const { name, ctrl, shift } = event;
|
|
174
|
+
|
|
175
|
+
if (quitConfirmPending) {
|
|
176
|
+
if (name === 'q') {
|
|
177
|
+
handleQuit();
|
|
178
|
+
} else {
|
|
179
|
+
handleQuitRequest();
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (name === 'q') {
|
|
185
|
+
handleQuitRequest();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (name === 'c' && ctrl) {
|
|
190
|
+
handleQuit();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const num = parseInt(name, 10);
|
|
195
|
+
if (num >= 1 && num <= activities.length) {
|
|
196
|
+
switchTab(num - 1);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (name === 'tab' && shift) {
|
|
201
|
+
switchTab((activeIndex - 1 + activities.length) % activities.length);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (name === 'tab') {
|
|
205
|
+
switchTab((activeIndex + 1) % activities.length);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (activities.length === 0) {
|
|
211
|
+
return (
|
|
212
|
+
<box flexDirection="column" padding={1}>
|
|
213
|
+
<text>Waiting for activities...</text>
|
|
214
|
+
{state.appName && (
|
|
215
|
+
<text fg="#666666">
|
|
216
|
+
{state.appName}
|
|
217
|
+
{state.version ? ` v${state.version}` : ''}
|
|
218
|
+
</text>
|
|
219
|
+
)}
|
|
220
|
+
</box>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!activeActivity) {
|
|
225
|
+
return <text>No active activity</text>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<box flexDirection="column" padding={1}>
|
|
230
|
+
<ActivityTabBar activities={activities} activeIndex={activeIndex} />
|
|
231
|
+
<box border={['top', 'bottom']} borderStyle="single" borderColor="#666666">
|
|
232
|
+
<ActivityView activity={activeActivity} viewHeight={viewHeight} />
|
|
233
|
+
</box>
|
|
234
|
+
<StatusBar activityCount={activities.length} quitConfirmPending={quitConfirmPending} />
|
|
235
|
+
</box>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Help Overlay for OpenTUI
|
|
3
|
+
*
|
|
4
|
+
* Displays keyboard shortcuts in a modal overlay.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { HELP_CONTENT } from '@pokit/tabs-core';
|
|
8
|
+
|
|
9
|
+
export type HelpOverlayProps = {
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function HelpOverlay(_props: HelpOverlayProps): React.ReactNode {
|
|
14
|
+
return (
|
|
15
|
+
<box
|
|
16
|
+
flexDirection="column"
|
|
17
|
+
border={['top', 'bottom', 'left', 'right']}
|
|
18
|
+
borderStyle="single"
|
|
19
|
+
borderColor="#0000FF"
|
|
20
|
+
padding={1}
|
|
21
|
+
>
|
|
22
|
+
<box justifyContent="center" marginBottom={1}>
|
|
23
|
+
<text fg="#FFFFFF">Keyboard Help</text>
|
|
24
|
+
</box>
|
|
25
|
+
|
|
26
|
+
{HELP_CONTENT.map((group, i) => (
|
|
27
|
+
<box
|
|
28
|
+
key={group.title}
|
|
29
|
+
flexDirection="column"
|
|
30
|
+
marginBottom={i < HELP_CONTENT.length - 1 ? 1 : 0}
|
|
31
|
+
>
|
|
32
|
+
<text fg="#00FFFF">{group.title}</text>
|
|
33
|
+
{group.shortcuts.map(({ key, description }) => (
|
|
34
|
+
<box key={key} flexDirection="row">
|
|
35
|
+
<box width={16}>
|
|
36
|
+
<text fg="#FFFF00">{key}</text>
|
|
37
|
+
</box>
|
|
38
|
+
<text>{description}</text>
|
|
39
|
+
</box>
|
|
40
|
+
))}
|
|
41
|
+
</box>
|
|
42
|
+
))}
|
|
43
|
+
|
|
44
|
+
<box marginTop={1} justifyContent="center">
|
|
45
|
+
<text fg="#666666">Press ? or Escape to close</text>
|
|
46
|
+
</box>
|
|
47
|
+
</box>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tabbed View Components for OpenTUI
|
|
3
|
+
*
|
|
4
|
+
* UI components for the tabbed terminal interface using OpenTUI primitives.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useKeyboard, useTerminalDimensions } from '@opentui/react';
|
|
8
|
+
import type { KeyEvent, ScrollBoxRenderable } from '@opentui/core';
|
|
9
|
+
import type { TabProcess } from '@pokit/tabs-core';
|
|
10
|
+
import {
|
|
11
|
+
useTabsState,
|
|
12
|
+
useKeyboardCallbackRefs,
|
|
13
|
+
processKeyEvent,
|
|
14
|
+
executeKeyboardAction,
|
|
15
|
+
type NormalizedKeyEvent,
|
|
16
|
+
type KeyboardCallbacks,
|
|
17
|
+
} from '@pokit/tabs-core';
|
|
18
|
+
import { HelpOverlay } from './help-overlay.js';
|
|
19
|
+
|
|
20
|
+
type TabbedViewProps = {
|
|
21
|
+
tabs: TabProcess[];
|
|
22
|
+
activeIndex: number;
|
|
23
|
+
onActiveIndexChange: (index: number) => void;
|
|
24
|
+
onQuit: () => void;
|
|
25
|
+
onQuitRequest: () => void;
|
|
26
|
+
onRestart: (index: number) => void;
|
|
27
|
+
onKill: (index: number) => void;
|
|
28
|
+
quitConfirmPending: boolean;
|
|
29
|
+
focusMode: boolean;
|
|
30
|
+
onEnterFocusMode: () => void;
|
|
31
|
+
onExitFocusMode: () => void;
|
|
32
|
+
onSendInput: (data: string) => void;
|
|
33
|
+
scrollRef?: (ref: ScrollBoxRenderable | null) => void;
|
|
34
|
+
helpVisible: boolean;
|
|
35
|
+
onToggleHelp: () => void;
|
|
36
|
+
onCloseHelp: () => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function getStatusIndicator({
|
|
40
|
+
status,
|
|
41
|
+
isActive,
|
|
42
|
+
}: {
|
|
43
|
+
status: TabProcess['status'];
|
|
44
|
+
isActive?: boolean;
|
|
45
|
+
}) {
|
|
46
|
+
switch (status) {
|
|
47
|
+
case 'running':
|
|
48
|
+
return { color: isActive ? '#00FFFF' : '#008B8B', icon: '\u25CF' };
|
|
49
|
+
case 'done':
|
|
50
|
+
return { color: isActive ? '#00FF00' : '#008000', icon: '\u2713' };
|
|
51
|
+
case 'error':
|
|
52
|
+
return { color: isActive ? '#FF0000' : '#8B0000', icon: '\u2717' };
|
|
53
|
+
case 'stopped':
|
|
54
|
+
return { color: isActive ? '#FFFF00' : '#808000', icon: '\u25A0' };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function TabBar({
|
|
59
|
+
tabs,
|
|
60
|
+
activeIndex,
|
|
61
|
+
focusMode,
|
|
62
|
+
}: {
|
|
63
|
+
tabs: TabProcess[];
|
|
64
|
+
activeIndex: number;
|
|
65
|
+
focusMode: boolean;
|
|
66
|
+
}) {
|
|
67
|
+
return (
|
|
68
|
+
<box flexDirection="row" gap={1} flexWrap="wrap">
|
|
69
|
+
{tabs.map((tab, i) => {
|
|
70
|
+
const isActive = i === activeIndex;
|
|
71
|
+
const { color, icon } = getStatusIndicator({
|
|
72
|
+
status: tab.status,
|
|
73
|
+
isActive,
|
|
74
|
+
});
|
|
75
|
+
return (
|
|
76
|
+
<box key={tab.id} flexDirection="row">
|
|
77
|
+
<text fg={color}> {icon} </text>
|
|
78
|
+
<box style={isActive ? { backgroundColor: '#444' } : {}}>
|
|
79
|
+
<text fg={isActive ? '#FFF' : '#888'}>
|
|
80
|
+
{' '}
|
|
81
|
+
{tab.label} ({i + 1}){' '}
|
|
82
|
+
</text>
|
|
83
|
+
</box>
|
|
84
|
+
</box>
|
|
85
|
+
);
|
|
86
|
+
})}
|
|
87
|
+
{focusMode && (
|
|
88
|
+
<box style={{ backgroundColor: '#FFFF00' }}>
|
|
89
|
+
<text fg="#000000"> INPUT MODE </text>
|
|
90
|
+
</box>
|
|
91
|
+
)}
|
|
92
|
+
</box>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function OutputView({
|
|
97
|
+
lines,
|
|
98
|
+
viewHeight,
|
|
99
|
+
isActive,
|
|
100
|
+
scrollRef,
|
|
101
|
+
}: {
|
|
102
|
+
lines: string[];
|
|
103
|
+
viewHeight: number;
|
|
104
|
+
isActive: boolean;
|
|
105
|
+
scrollRef?: (ref: ScrollBoxRenderable | null) => void;
|
|
106
|
+
}) {
|
|
107
|
+
return (
|
|
108
|
+
<scrollbox
|
|
109
|
+
ref={scrollRef}
|
|
110
|
+
height={viewHeight}
|
|
111
|
+
scrollY={true}
|
|
112
|
+
focused={isActive}
|
|
113
|
+
viewportCulling={false}
|
|
114
|
+
>
|
|
115
|
+
{lines.map((line, i) => (
|
|
116
|
+
<text key={i}>{line || ' '}</text>
|
|
117
|
+
))}
|
|
118
|
+
</scrollbox>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function StatusBar({
|
|
123
|
+
tabCount,
|
|
124
|
+
quitConfirmPending,
|
|
125
|
+
focusMode,
|
|
126
|
+
showHelpHint,
|
|
127
|
+
}: {
|
|
128
|
+
tabCount: number;
|
|
129
|
+
quitConfirmPending: boolean;
|
|
130
|
+
focusMode: boolean;
|
|
131
|
+
showHelpHint: boolean;
|
|
132
|
+
}) {
|
|
133
|
+
if (quitConfirmPending) {
|
|
134
|
+
return (
|
|
135
|
+
<box>
|
|
136
|
+
<box style={{ backgroundColor: '#FFFF00' }}>
|
|
137
|
+
<text fg="#000000"> Press q again to quit, any other key to cancel </text>
|
|
138
|
+
</box>
|
|
139
|
+
</box>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (focusMode) {
|
|
144
|
+
return (
|
|
145
|
+
<box flexDirection="row">
|
|
146
|
+
<box style={{ backgroundColor: '#00FFFF' }}>
|
|
147
|
+
<text fg="#000000"> INPUT MODE </text>
|
|
148
|
+
</box>
|
|
149
|
+
<text fg="#666666"> Press Esc to exit input mode</text>
|
|
150
|
+
</box>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<box>
|
|
156
|
+
<text fg="#666666">
|
|
157
|
+
[{'\u2191\u2193'}] scroll | [Tab/1-{tabCount}] switch | [i]nput | [r]estart | [k]ill |
|
|
158
|
+
[q]uit{showHelpHint && ' | Press ? for help'}
|
|
159
|
+
</text>
|
|
160
|
+
</box>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Normalize OpenTUI's KeyEvent to the shared NormalizedKeyEvent format.
|
|
166
|
+
*/
|
|
167
|
+
function normalizeOpenTUIKeyEvent(event: KeyEvent): NormalizedKeyEvent {
|
|
168
|
+
const { name, ctrl, shift, meta, sequence } = event;
|
|
169
|
+
|
|
170
|
+
// Map OpenTUI key names to normalized names
|
|
171
|
+
let normalizedName: string | undefined;
|
|
172
|
+
if (name === 'escape') normalizedName = 'escape';
|
|
173
|
+
else if (name === 'return') normalizedName = 'return';
|
|
174
|
+
else if (name === 'tab') normalizedName = 'tab';
|
|
175
|
+
else if (name === 'backspace') normalizedName = 'backspace';
|
|
176
|
+
else if (name === 'delete') normalizedName = 'delete';
|
|
177
|
+
else if (name === 'up') normalizedName = 'up';
|
|
178
|
+
else if (name === 'down') normalizedName = 'down';
|
|
179
|
+
else if (name === 'left') normalizedName = 'left';
|
|
180
|
+
else if (name === 'right') normalizedName = 'right';
|
|
181
|
+
else if (name === 'pageup') normalizedName = 'pageup';
|
|
182
|
+
else if (name === 'pagedown') normalizedName = 'pagedown';
|
|
183
|
+
else normalizedName = name;
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
char: sequence || undefined,
|
|
187
|
+
name: normalizedName,
|
|
188
|
+
ctrl,
|
|
189
|
+
shift,
|
|
190
|
+
meta,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function TabbedView({
|
|
195
|
+
tabs,
|
|
196
|
+
activeIndex,
|
|
197
|
+
onActiveIndexChange,
|
|
198
|
+
onQuit,
|
|
199
|
+
onQuitRequest,
|
|
200
|
+
onRestart,
|
|
201
|
+
onKill,
|
|
202
|
+
quitConfirmPending,
|
|
203
|
+
focusMode,
|
|
204
|
+
onEnterFocusMode,
|
|
205
|
+
onExitFocusMode,
|
|
206
|
+
onSendInput,
|
|
207
|
+
scrollRef,
|
|
208
|
+
helpVisible,
|
|
209
|
+
onToggleHelp,
|
|
210
|
+
onCloseHelp,
|
|
211
|
+
}: TabbedViewProps): React.ReactNode {
|
|
212
|
+
const { height: rows } = useTerminalDimensions();
|
|
213
|
+
|
|
214
|
+
const terminalHeight = rows ?? 24;
|
|
215
|
+
const viewHeight = Math.max(5, terminalHeight - 6);
|
|
216
|
+
|
|
217
|
+
// Use shared tabs state hook
|
|
218
|
+
// Note: OpenTUI's scrollbox handles scrolling natively, so we don't use scrollBy here
|
|
219
|
+
const { showHelpHint, switchTab, nextTab, prevTab, scrollBy } = useTabsState({
|
|
220
|
+
tabs,
|
|
221
|
+
activeIndex,
|
|
222
|
+
onActiveIndexChange,
|
|
223
|
+
viewHeight,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Use shared callback refs hook
|
|
227
|
+
const callbacks: KeyboardCallbacks = {
|
|
228
|
+
onQuit,
|
|
229
|
+
onQuitRequest,
|
|
230
|
+
onRestart,
|
|
231
|
+
onKill,
|
|
232
|
+
onEnterFocusMode,
|
|
233
|
+
onExitFocusMode,
|
|
234
|
+
onSendInput,
|
|
235
|
+
onToggleHelp,
|
|
236
|
+
onCloseHelp,
|
|
237
|
+
};
|
|
238
|
+
const callbackRefs = useKeyboardCallbackRefs(callbacks);
|
|
239
|
+
|
|
240
|
+
useKeyboard((event: KeyEvent) => {
|
|
241
|
+
const normalizedEvent = normalizeOpenTUIKeyEvent(event);
|
|
242
|
+
const action = processKeyEvent(
|
|
243
|
+
normalizedEvent,
|
|
244
|
+
{
|
|
245
|
+
helpVisible,
|
|
246
|
+
focusMode,
|
|
247
|
+
quitConfirmPending,
|
|
248
|
+
activeIndex,
|
|
249
|
+
tabCount: tabs.length,
|
|
250
|
+
},
|
|
251
|
+
viewHeight
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// For OpenTUI, we skip scroll actions since native scrollbox handles them
|
|
255
|
+
if (action.type === 'scroll') {
|
|
256
|
+
// OpenTUI's scrollbox handles up/down/pageup/pagedown natively
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
executeKeyboardAction(action, callbackRefs, scrollBy, switchTab, nextTab, prevTab);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const activeTab = tabs[activeIndex];
|
|
264
|
+
if (!activeTab) {
|
|
265
|
+
return <text>No tabs available</text>;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<box flexDirection="column" padding={1}>
|
|
270
|
+
<TabBar tabs={tabs} activeIndex={activeIndex} focusMode={focusMode} />
|
|
271
|
+
<box
|
|
272
|
+
border={['top', 'bottom']}
|
|
273
|
+
borderStyle="single"
|
|
274
|
+
borderColor="#666666"
|
|
275
|
+
height={viewHeight + 2}
|
|
276
|
+
>
|
|
277
|
+
<OutputView
|
|
278
|
+
lines={activeTab.output}
|
|
279
|
+
viewHeight={viewHeight}
|
|
280
|
+
isActive={true}
|
|
281
|
+
scrollRef={scrollRef}
|
|
282
|
+
/>
|
|
283
|
+
</box>
|
|
284
|
+
<StatusBar
|
|
285
|
+
tabCount={tabs.length}
|
|
286
|
+
quitConfirmPending={quitConfirmPending}
|
|
287
|
+
focusMode={focusMode}
|
|
288
|
+
showHelpHint={showHelpHint}
|
|
289
|
+
/>
|
|
290
|
+
|
|
291
|
+
{/* Help overlay - rendered on top */}
|
|
292
|
+
{helpVisible && (
|
|
293
|
+
<box
|
|
294
|
+
position="absolute"
|
|
295
|
+
flexDirection="column"
|
|
296
|
+
alignItems="center"
|
|
297
|
+
justifyContent="center"
|
|
298
|
+
width="100%"
|
|
299
|
+
height="100%"
|
|
300
|
+
>
|
|
301
|
+
<HelpOverlay onClose={onCloseHelp} />
|
|
302
|
+
</box>
|
|
303
|
+
)}
|
|
304
|
+
</box>
|
|
305
|
+
);
|
|
306
|
+
}
|
package/src/tabs-app.tsx
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tabs App for OpenTUI
|
|
3
|
+
*
|
|
4
|
+
* Wires ProcessManager from core to React state.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
8
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
9
|
+
import stripAnsi from 'strip-ansi';
|
|
10
|
+
import { TabbedView } from './tabbed-view.js';
|
|
11
|
+
import {
|
|
12
|
+
OutputBuffer,
|
|
13
|
+
MAX_OUTPUT_LINES,
|
|
14
|
+
MAX_LINE_LENGTH,
|
|
15
|
+
BUFFER_WARNING_THRESHOLD,
|
|
16
|
+
} from '@pokit/tabs-core';
|
|
17
|
+
import type { TabSpec, TabProcess } from '@pokit/tabs-core';
|
|
18
|
+
import type { TabsOptions } from '@pokit/core';
|
|
19
|
+
import type { ScrollBoxRenderable } from '@opentui/core';
|
|
20
|
+
|
|
21
|
+
const OUTPUT_BATCH_MS = 16;
|
|
22
|
+
/** How many pixels from bottom to consider "near bottom" for auto-scroll */
|
|
23
|
+
const NEAR_BOTTOM_THRESHOLD = 50;
|
|
24
|
+
/** Delay before scrolling to allow React to render new content */
|
|
25
|
+
const SCROLL_DELAY_MS = 50;
|
|
26
|
+
|
|
27
|
+
type TabsAppProps = {
|
|
28
|
+
items: TabSpec[];
|
|
29
|
+
options: TabsOptions;
|
|
30
|
+
onExit: (code: number) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Temporary buffer for batching incoming output before flushing to state */
|
|
34
|
+
type BatchBuffer = {
|
|
35
|
+
lines: string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function TabsApp({ items, options, onExit }: TabsAppProps): React.ReactNode {
|
|
39
|
+
// Ring buffers for storing all output per tab (with O(1) operations)
|
|
40
|
+
const ringBuffersRef = useRef<Map<string, OutputBuffer>>(new Map());
|
|
41
|
+
|
|
42
|
+
// Initialize ring buffers for each tab
|
|
43
|
+
const getOrCreateRingBuffer = useCallback((tabId: string): OutputBuffer => {
|
|
44
|
+
let buffer = ringBuffersRef.current.get(tabId);
|
|
45
|
+
if (!buffer) {
|
|
46
|
+
buffer = new OutputBuffer({
|
|
47
|
+
maxLines: MAX_OUTPUT_LINES,
|
|
48
|
+
maxLineLength: MAX_LINE_LENGTH,
|
|
49
|
+
warnAtPercentage: BUFFER_WARNING_THRESHOLD,
|
|
50
|
+
tabId,
|
|
51
|
+
// Optional: could add onPressure callback here for UI feedback
|
|
52
|
+
});
|
|
53
|
+
ringBuffersRef.current.set(tabId, buffer);
|
|
54
|
+
}
|
|
55
|
+
return buffer;
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
const [tabs, setTabs] = useState<TabProcess[]>(() =>
|
|
59
|
+
items.map((item, i) => ({
|
|
60
|
+
id: `tab-${i}`,
|
|
61
|
+
label: item.label,
|
|
62
|
+
exec: item.exec,
|
|
63
|
+
output: [],
|
|
64
|
+
status: 'running' as const,
|
|
65
|
+
}))
|
|
66
|
+
);
|
|
67
|
+
const [quitConfirmPending, setQuitConfirmPending] = useState(false);
|
|
68
|
+
const [focusMode, setFocusMode] = useState(false);
|
|
69
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
70
|
+
const [helpVisible, setHelpVisible] = useState(false);
|
|
71
|
+
const processesRef = useRef<(ChildProcess | null)[]>([]);
|
|
72
|
+
const batchBuffersRef = useRef<Map<number, BatchBuffer>>(new Map());
|
|
73
|
+
const flushScheduledRef = useRef(false);
|
|
74
|
+
const scrollBoxRef = useRef<ScrollBoxRenderable | null>(null);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if the scrollbox is near the bottom.
|
|
78
|
+
* Returns true if within NEAR_BOTTOM_THRESHOLD pixels of bottom, or if content fits in viewport.
|
|
79
|
+
*/
|
|
80
|
+
const checkIfNearBottom = useCallback((): boolean => {
|
|
81
|
+
const scroll = scrollBoxRef.current;
|
|
82
|
+
if (!scroll) return true;
|
|
83
|
+
|
|
84
|
+
const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.viewport.height);
|
|
85
|
+
if (maxScrollTop <= 0) return true; // Content fits in viewport
|
|
86
|
+
|
|
87
|
+
const distanceFromBottom = maxScrollTop - scroll.scrollTop;
|
|
88
|
+
return distanceFromBottom <= NEAR_BOTTOM_THRESHOLD;
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Scroll to bottom immediately (synchronous). Used for tab switches to avoid visible snap.
|
|
93
|
+
*/
|
|
94
|
+
const scrollToBottomImmediate = useCallback(() => {
|
|
95
|
+
const scroll = scrollBoxRef.current;
|
|
96
|
+
if (scroll) {
|
|
97
|
+
scroll.scrollTo(scroll.scrollHeight);
|
|
98
|
+
}
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Scroll to bottom with delay. Called after content updates to let React render first.
|
|
103
|
+
*/
|
|
104
|
+
const scrollToBottom = useCallback(() => {
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
scrollToBottomImmediate();
|
|
107
|
+
}, SCROLL_DELAY_MS);
|
|
108
|
+
}, [scrollToBottomImmediate]);
|
|
109
|
+
|
|
110
|
+
const handleScrollRef = useCallback(
|
|
111
|
+
(ref: ScrollBoxRenderable | null) => {
|
|
112
|
+
scrollBoxRef.current = ref;
|
|
113
|
+
// Initial scroll to bottom when ref is set - immediate to avoid snap
|
|
114
|
+
if (ref) {
|
|
115
|
+
scrollToBottomImmediate();
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
[scrollToBottomImmediate]
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const flushOutput = useCallback(() => {
|
|
122
|
+
flushScheduledRef.current = false;
|
|
123
|
+
|
|
124
|
+
const buffersSnapshot = batchBuffersRef.current;
|
|
125
|
+
batchBuffersRef.current = new Map();
|
|
126
|
+
|
|
127
|
+
if (buffersSnapshot.size === 0) return;
|
|
128
|
+
|
|
129
|
+
const updates: Array<{ index: number; lines: string[] }> = [];
|
|
130
|
+
for (const [index, buffer] of buffersSnapshot.entries()) {
|
|
131
|
+
if (buffer.lines.length > 0) {
|
|
132
|
+
updates.push({ index, lines: buffer.lines });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (updates.length === 0) return;
|
|
137
|
+
|
|
138
|
+
// Check if near bottom BEFORE updating state (while old content is rendered)
|
|
139
|
+
const wasNearBottom = checkIfNearBottom();
|
|
140
|
+
|
|
141
|
+
setTabs((prev) => {
|
|
142
|
+
const next = [...prev];
|
|
143
|
+
for (const { index, lines } of updates) {
|
|
144
|
+
const tab = next[index];
|
|
145
|
+
if (!tab) continue;
|
|
146
|
+
|
|
147
|
+
// Push to ring buffer and get output with dropped indicator
|
|
148
|
+
const ringBuffer = getOrCreateRingBuffer(tab.id);
|
|
149
|
+
ringBuffer.push(...lines);
|
|
150
|
+
next[index] = { ...tab, output: ringBuffer.toArray() };
|
|
151
|
+
}
|
|
152
|
+
return next;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// If we were near bottom, scroll to bottom after content updates
|
|
156
|
+
if (wasNearBottom) {
|
|
157
|
+
scrollToBottom();
|
|
158
|
+
}
|
|
159
|
+
}, [checkIfNearBottom, scrollToBottom, getOrCreateRingBuffer]);
|
|
160
|
+
|
|
161
|
+
const scheduleFlush = useCallback(() => {
|
|
162
|
+
if (flushScheduledRef.current) return;
|
|
163
|
+
flushScheduledRef.current = true;
|
|
164
|
+
setTimeout(flushOutput, OUTPUT_BATCH_MS);
|
|
165
|
+
}, [flushOutput]);
|
|
166
|
+
|
|
167
|
+
const appendOutput = useCallback(
|
|
168
|
+
(index: number, data: Buffer) => {
|
|
169
|
+
const rawText = data.toString('utf-8');
|
|
170
|
+
// Strip ALL ANSI sequences to prevent rendering artifacts
|
|
171
|
+
// We lose colors but gain reliable scrolling
|
|
172
|
+
const text = stripAnsi(rawText)
|
|
173
|
+
// Also convert lone carriage returns to newlines (progress bars)
|
|
174
|
+
.replace(/\r(?!\n)/g, '\n');
|
|
175
|
+
const lines = text.split(/\r?\n/).filter((line, idx, arr) => {
|
|
176
|
+
return line || idx < arr.length - 1;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (lines.length === 0) return;
|
|
180
|
+
|
|
181
|
+
let buffer = batchBuffersRef.current.get(index);
|
|
182
|
+
if (!buffer) {
|
|
183
|
+
buffer = { lines: [] };
|
|
184
|
+
batchBuffersRef.current.set(index, buffer);
|
|
185
|
+
}
|
|
186
|
+
buffer.lines.push(...lines);
|
|
187
|
+
|
|
188
|
+
scheduleFlush();
|
|
189
|
+
},
|
|
190
|
+
[scheduleFlush]
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const spawnProcess = useCallback(
|
|
194
|
+
(index: number): ChildProcess | null => {
|
|
195
|
+
const item = items[index];
|
|
196
|
+
if (!item) return null;
|
|
197
|
+
|
|
198
|
+
const proc = spawn('sh', ['-c', item.exec], {
|
|
199
|
+
cwd: options.cwd,
|
|
200
|
+
env: {
|
|
201
|
+
...options.env,
|
|
202
|
+
FORCE_COLOR: '1',
|
|
203
|
+
} as NodeJS.ProcessEnv,
|
|
204
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const handleData = (data: Buffer) => appendOutput(index, data);
|
|
208
|
+
|
|
209
|
+
proc.stdout?.on('data', handleData);
|
|
210
|
+
proc.stderr?.on('data', handleData);
|
|
211
|
+
|
|
212
|
+
proc.on('close', (code) => {
|
|
213
|
+
flushOutput();
|
|
214
|
+
setTabs((prev) => {
|
|
215
|
+
const next = [...prev];
|
|
216
|
+
const tab = next[index];
|
|
217
|
+
if (!tab) return prev;
|
|
218
|
+
next[index] = {
|
|
219
|
+
...tab,
|
|
220
|
+
status: code === 0 ? 'done' : 'error',
|
|
221
|
+
exitCode: code ?? undefined,
|
|
222
|
+
};
|
|
223
|
+
return next;
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
proc.on('error', (err) => {
|
|
228
|
+
setTabs((prev) => {
|
|
229
|
+
const next = [...prev];
|
|
230
|
+
const tab = next[index];
|
|
231
|
+
if (!tab) return prev;
|
|
232
|
+
next[index] = {
|
|
233
|
+
...tab,
|
|
234
|
+
status: 'error',
|
|
235
|
+
output: [...tab.output, `Error: ${err.message}`],
|
|
236
|
+
};
|
|
237
|
+
return next;
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return proc;
|
|
242
|
+
},
|
|
243
|
+
[items, options.cwd, options.env, appendOutput, flushOutput]
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
const procs = items.map((_, i) => spawnProcess(i));
|
|
248
|
+
processesRef.current = procs;
|
|
249
|
+
|
|
250
|
+
return () => {
|
|
251
|
+
for (const proc of processesRef.current) {
|
|
252
|
+
if (proc && !proc.killed) {
|
|
253
|
+
proc.kill('SIGTERM');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}, [items, spawnProcess]);
|
|
258
|
+
|
|
259
|
+
const killAll = useCallback(() => {
|
|
260
|
+
for (const proc of processesRef.current) {
|
|
261
|
+
if (proc && !proc.killed) {
|
|
262
|
+
proc.kill('SIGTERM');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
const handleRestart = useCallback(
|
|
268
|
+
(index: number) => {
|
|
269
|
+
const existingProc = processesRef.current[index];
|
|
270
|
+
if (existingProc && !existingProc.killed) {
|
|
271
|
+
existingProc.kill('SIGTERM');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Clear both batch buffer and ring buffer
|
|
275
|
+
batchBuffersRef.current.delete(index);
|
|
276
|
+
const tabId = `tab-${index}`;
|
|
277
|
+
ringBuffersRef.current.get(tabId)?.clear();
|
|
278
|
+
|
|
279
|
+
setTabs((prev) => {
|
|
280
|
+
const next = [...prev];
|
|
281
|
+
const tab = next[index];
|
|
282
|
+
if (!tab) return prev;
|
|
283
|
+
next[index] = {
|
|
284
|
+
...tab,
|
|
285
|
+
output: ['Restarting...', ''],
|
|
286
|
+
status: 'running',
|
|
287
|
+
exitCode: undefined,
|
|
288
|
+
};
|
|
289
|
+
return next;
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Scroll to bottom after restart since output is cleared
|
|
293
|
+
scrollToBottom();
|
|
294
|
+
|
|
295
|
+
setTimeout(() => {
|
|
296
|
+
const newProc = spawnProcess(index);
|
|
297
|
+
processesRef.current[index] = newProc;
|
|
298
|
+
}, 100);
|
|
299
|
+
},
|
|
300
|
+
[spawnProcess, scrollToBottom]
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const handleKill = useCallback((index: number) => {
|
|
304
|
+
const proc = processesRef.current[index];
|
|
305
|
+
if (proc && !proc.killed) {
|
|
306
|
+
proc.kill('SIGTERM');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
setTabs((prev) => {
|
|
310
|
+
const next = [...prev];
|
|
311
|
+
const tab = next[index];
|
|
312
|
+
if (!tab) return prev;
|
|
313
|
+
if (tab.status !== 'running') return prev;
|
|
314
|
+
next[index] = {
|
|
315
|
+
...tab,
|
|
316
|
+
status: 'stopped',
|
|
317
|
+
output: [...tab.output, '', 'Stopped'],
|
|
318
|
+
};
|
|
319
|
+
return next;
|
|
320
|
+
});
|
|
321
|
+
}, []);
|
|
322
|
+
|
|
323
|
+
const handleQuitRequest = useCallback(() => {
|
|
324
|
+
setQuitConfirmPending((prev) => !prev);
|
|
325
|
+
}, []);
|
|
326
|
+
|
|
327
|
+
const handleQuit = useCallback(() => {
|
|
328
|
+
killAll();
|
|
329
|
+
onExit(0);
|
|
330
|
+
}, [killAll, onExit]);
|
|
331
|
+
|
|
332
|
+
const handleEnterFocusMode = useCallback(() => {
|
|
333
|
+
const proc = processesRef.current[activeIndex];
|
|
334
|
+
if (proc && !proc.killed && proc.stdin) {
|
|
335
|
+
setFocusMode(true);
|
|
336
|
+
}
|
|
337
|
+
}, [activeIndex]);
|
|
338
|
+
|
|
339
|
+
const handleExitFocusMode = useCallback(() => {
|
|
340
|
+
setFocusMode(false);
|
|
341
|
+
}, []);
|
|
342
|
+
|
|
343
|
+
const handleSendInput = useCallback(
|
|
344
|
+
(data: string) => {
|
|
345
|
+
const proc = processesRef.current[activeIndex];
|
|
346
|
+
if (proc && !proc.killed && proc.stdin) {
|
|
347
|
+
proc.stdin.write(data);
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
[activeIndex]
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const handleActiveIndexChange = useCallback(
|
|
354
|
+
(index: number) => {
|
|
355
|
+
setActiveIndex(index);
|
|
356
|
+
setFocusMode(false);
|
|
357
|
+
// Scroll to bottom when switching tabs
|
|
358
|
+
scrollToBottom();
|
|
359
|
+
},
|
|
360
|
+
[scrollToBottom]
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const handleToggleHelp = useCallback(() => {
|
|
364
|
+
setHelpVisible((prev) => !prev);
|
|
365
|
+
}, []);
|
|
366
|
+
|
|
367
|
+
const handleCloseHelp = useCallback(() => {
|
|
368
|
+
setHelpVisible(false);
|
|
369
|
+
}, []);
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<TabbedView
|
|
373
|
+
tabs={tabs}
|
|
374
|
+
activeIndex={activeIndex}
|
|
375
|
+
onActiveIndexChange={handleActiveIndexChange}
|
|
376
|
+
onQuit={handleQuit}
|
|
377
|
+
onQuitRequest={handleQuitRequest}
|
|
378
|
+
onRestart={handleRestart}
|
|
379
|
+
onKill={handleKill}
|
|
380
|
+
quitConfirmPending={quitConfirmPending}
|
|
381
|
+
focusMode={focusMode}
|
|
382
|
+
onEnterFocusMode={handleEnterFocusMode}
|
|
383
|
+
onExitFocusMode={handleExitFocusMode}
|
|
384
|
+
onSendInput={handleSendInput}
|
|
385
|
+
scrollRef={handleScrollRef}
|
|
386
|
+
helpVisible={helpVisible}
|
|
387
|
+
onToggleHelp={handleToggleHelp}
|
|
388
|
+
onCloseHelp={handleCloseHelp}
|
|
389
|
+
/>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
@@ -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
|
+
}
|