@pokit/tabs-opentui 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Daniel Grant
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,26 @@
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
+ import type { TabsAdapter, EventBus } from '@pokit/core';
8
+ /**
9
+ * Create a tabs adapter using OpenTUI
10
+ */
11
+ export declare function createTabsAdapter(): TabsAdapter;
12
+ export type EventAdapterOptions = {
13
+ onExit?: (code: number) => void;
14
+ };
15
+ /**
16
+ * Create an event-driven adapter that renders based on EventBus events.
17
+ * This adapter builds a state tree from events and renders it using OpenTUI.
18
+ *
19
+ * @param bus - The EventBus to listen to
20
+ * @param options - Options for the adapter
21
+ * @returns Object with unmount function to stop the adapter
22
+ */
23
+ export declare function createEventAdapter(bus: EventBus, options?: EventAdapterOptions): {
24
+ unmount: () => void;
25
+ };
26
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,WAAW,EAAwB,QAAQ,EAAE,MAAM,aAAa,CAAC;AAK/E;;GAEG;AACH,wBAAgB,iBAAiB,IAAI,WAAW,CAyG/C;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACjC,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,QAAQ,EACb,OAAO,GAAE,mBAAwB,GAChC;IAAE,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CAuGzB"}
@@ -0,0 +1,188 @@
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
+ import * as React from 'react';
8
+ import { createCliRenderer } from '@opentui/core';
9
+ import { createRoot } from '@opentui/react';
10
+ import { TabsApp } from './tabs-app.js';
11
+ import { EventDrivenApp } from './event-driven-app.js';
12
+ import { TabsErrorBoundary, restoreTerminal } from './error-boundary.js';
13
+ /**
14
+ * Create a tabs adapter using OpenTUI
15
+ */
16
+ export function createTabsAdapter() {
17
+ return {
18
+ async run(items, options) {
19
+ if (!process.stdout.isTTY) {
20
+ throw new Error('Tabbed view requires stdout to be a TTY');
21
+ }
22
+ if (!process.stdin.isTTY) {
23
+ throw new Error('Tabbed view requires stdin to be a TTY for keyboard input');
24
+ }
25
+ if (items.length === 0) {
26
+ return;
27
+ }
28
+ // Ensure stdin is not paused - previous CLI operations (prompts, spinners)
29
+ // may have left it in a paused state
30
+ if (process.stdin.isPaused()) {
31
+ process.stdin.resume();
32
+ }
33
+ // Enable raw mode before OpenTUI starts - this ensures stdin is ready
34
+ // to receive escape sequence responses for capability detection
35
+ process.stdin.setRawMode(true);
36
+ // Let OpenTUI handle alternate screen via its config
37
+ const renderer = await createCliRenderer({
38
+ exitOnCtrlC: false,
39
+ useAlternateScreen: true,
40
+ useMouse: true, // Enable mouse for scroll wheel support
41
+ useKittyKeyboard: {}, // Enable Kitty keyboard protocol for better key handling
42
+ });
43
+ // Disable stdout interception to prevent output mangling with scrolling
44
+ renderer.disableStdoutInterception();
45
+ const root = createRoot(renderer);
46
+ // Start the render loop explicitly
47
+ renderer.start();
48
+ return new Promise((resolve) => {
49
+ let resolved = false;
50
+ // Cleanup function to restore terminal and resolve
51
+ const cleanup = () => {
52
+ if (resolved)
53
+ return;
54
+ resolved = true;
55
+ // Remove signal handlers
56
+ process.removeListener('SIGTERM', handleSignal);
57
+ process.removeListener('SIGQUIT', handleSignal);
58
+ process.removeListener('uncaughtException', handleUncaughtException);
59
+ try {
60
+ root.unmount();
61
+ renderer.destroy();
62
+ }
63
+ catch {
64
+ // Ignore errors during cleanup
65
+ }
66
+ restoreTerminal();
67
+ resolve();
68
+ };
69
+ // Signal handler for graceful shutdown
70
+ const handleSignal = () => {
71
+ cleanup();
72
+ process.exit(0);
73
+ };
74
+ // Handle uncaught exceptions
75
+ const handleUncaughtException = (error) => {
76
+ restoreTerminal();
77
+ console.error('\n[TabsUI] Uncaught exception:', error);
78
+ cleanup();
79
+ process.exit(1);
80
+ };
81
+ // Register signal handlers
82
+ process.on('SIGTERM', handleSignal);
83
+ process.on('SIGQUIT', handleSignal);
84
+ process.on('uncaughtException', handleUncaughtException);
85
+ // Handle fatal errors from error boundary
86
+ const handleFatalError = () => {
87
+ cleanup();
88
+ };
89
+ const handleExit = () => {
90
+ cleanup();
91
+ };
92
+ // Use React.createElement to bypass OpenTUI's JSX type constraints for class components
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
94
+ root.render(React.createElement(TabsErrorBoundary, { onFatalError: handleFatalError }, React.createElement(TabsApp, { items, options, onExit: handleExit })));
95
+ });
96
+ },
97
+ };
98
+ }
99
+ /**
100
+ * Create an event-driven adapter that renders based on EventBus events.
101
+ * This adapter builds a state tree from events and renders it using OpenTUI.
102
+ *
103
+ * @param bus - The EventBus to listen to
104
+ * @param options - Options for the adapter
105
+ * @returns Object with unmount function to stop the adapter
106
+ */
107
+ export function createEventAdapter(bus, options = {}) {
108
+ if (!process.stdout.isTTY) {
109
+ throw new Error('Event-driven tabs view requires stdout to be a TTY');
110
+ }
111
+ if (!process.stdin.isTTY) {
112
+ throw new Error('Event-driven tabs view requires stdin to be a TTY for keyboard input');
113
+ }
114
+ let root = null;
115
+ let renderer = null;
116
+ let isCleanedUp = false;
117
+ // Cleanup function to restore terminal
118
+ const cleanup = () => {
119
+ if (isCleanedUp)
120
+ return;
121
+ isCleanedUp = true;
122
+ // Remove signal handlers
123
+ process.removeListener('SIGTERM', handleSignal);
124
+ process.removeListener('SIGQUIT', handleSignal);
125
+ process.removeListener('uncaughtException', handleUncaughtException);
126
+ try {
127
+ root?.unmount();
128
+ renderer?.destroy();
129
+ }
130
+ catch {
131
+ // Ignore errors during cleanup
132
+ }
133
+ restoreTerminal();
134
+ };
135
+ // Signal handler for graceful shutdown
136
+ const handleSignal = () => {
137
+ cleanup();
138
+ process.exit(0);
139
+ };
140
+ // Handle uncaught exceptions
141
+ const handleUncaughtException = (error) => {
142
+ restoreTerminal();
143
+ console.error('\n[TabsUI] Uncaught exception:', error);
144
+ cleanup();
145
+ process.exit(1);
146
+ };
147
+ // Register signal handlers
148
+ process.on('SIGTERM', handleSignal);
149
+ process.on('SIGQUIT', handleSignal);
150
+ process.on('uncaughtException', handleUncaughtException);
151
+ const init = async () => {
152
+ // Let OpenTUI handle alternate screen and raw mode via its config
153
+ renderer = await createCliRenderer({
154
+ exitOnCtrlC: false,
155
+ useAlternateScreen: true,
156
+ useMouse: false,
157
+ useKittyKeyboard: {}, // Enable Kitty keyboard protocol for better key handling
158
+ });
159
+ // Check if cleanup was requested during async init
160
+ if (isCleanedUp) {
161
+ renderer.destroy();
162
+ return;
163
+ }
164
+ // Disable stdout interception to prevent output mangling with scrolling
165
+ renderer.disableStdoutInterception();
166
+ root = createRoot(renderer);
167
+ // Start the render loop explicitly
168
+ renderer.start();
169
+ const handleExit = (code) => {
170
+ cleanup();
171
+ options.onExit?.(code);
172
+ };
173
+ // Handle fatal errors from error boundary
174
+ const handleFatalError = () => {
175
+ cleanup();
176
+ };
177
+ // Use React.createElement to bypass OpenTUI's JSX type constraints for class components
178
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
179
+ root.render(React.createElement(TabsErrorBoundary, { onFatalError: handleFatalError }, React.createElement(EventDrivenApp, { bus, onExit: handleExit })));
180
+ };
181
+ init().catch((error) => {
182
+ console.error('Failed to initialize event adapter:', error);
183
+ restoreTerminal();
184
+ });
185
+ return {
186
+ unmount: cleanup,
187
+ };
188
+ }
@@ -0,0 +1,40 @@
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
+ import * as React from 'react';
9
+ export type ErrorBoundaryProps = {
10
+ children?: React.ReactNode;
11
+ onFatalError?: (error: Error, errorInfo: React.ErrorInfo) => void;
12
+ };
13
+ type ErrorBoundaryState = {
14
+ hasError: boolean;
15
+ error: Error | null;
16
+ };
17
+ /**
18
+ * Restore terminal to a clean state.
19
+ * Called on errors and signal handlers to ensure terminal isn't left corrupted.
20
+ */
21
+ export declare function restoreTerminal(): void;
22
+ /**
23
+ * Internal React Error Boundary class that ensures terminal cleanup on crashes.
24
+ * For OpenTUI, we use a simple fallback since OpenTUI components may not
25
+ * render correctly after an error.
26
+ */
27
+ declare class ErrorBoundaryClass extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
28
+ constructor(props: ErrorBoundaryProps);
29
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState;
30
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void;
31
+ render(): React.ReactNode;
32
+ }
33
+ /**
34
+ * Export the error boundary class.
35
+ * Note: When using in JSX with OpenTUI, you may need to use React.createElement directly
36
+ * due to OpenTUI's JSX type constraints.
37
+ */
38
+ export declare const TabsErrorBoundary: typeof ErrorBoundaryClass;
39
+ export {};
40
+ //# sourceMappingURL=error-boundary.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error-boundary.d.ts","sourceRoot":"","sources":["../src/error-boundary.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,KAAK,IAAI,CAAC;CACnE,CAAC;AAEF,KAAK,kBAAkB,GAAG;IACxB,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;CACrB,CAAC;AAEF;;;GAGG;AACH,wBAAgB,eAAe,IAAI,IAAI,CAgBtC;AAED;;;;GAIG;AACH,cAAM,kBAAmB,SAAQ,KAAK,CAAC,SAAS,CAAC,kBAAkB,EAAE,kBAAkB,CAAC;gBAC1E,KAAK,EAAE,kBAAkB;IAKrC,MAAM,CAAC,wBAAwB,CAAC,KAAK,EAAE,KAAK,GAAG,kBAAkB;IAIjE,iBAAiB,CAAC,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,GAAG,IAAI;IAejE,MAAM,IAAI,KAAK,CAAC,SAAS;CAe1B;AAED;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,2BAAqB,CAAC"}
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ /**
3
+ * Error Boundary for Tabs UI (OpenTUI)
4
+ *
5
+ * Catches React errors and ensures terminal state is restored before displaying error.
6
+ * This prevents the terminal from being left in a corrupted state (alternate screen,
7
+ * hidden cursor, raw mode) when React rendering crashes.
8
+ */
9
+ import * as React from 'react';
10
+ /**
11
+ * Restore terminal to a clean state.
12
+ * Called on errors and signal handlers to ensure terminal isn't left corrupted.
13
+ */
14
+ export function restoreTerminal() {
15
+ // Exit alternate screen buffer
16
+ process.stdout.write('\x1b[?1049l');
17
+ // Show cursor (in case it was hidden)
18
+ process.stdout.write('\x1b[?25h');
19
+ // Reset all attributes (colors, styles)
20
+ process.stdout.write('\x1b[0m');
21
+ // Try to restore raw mode if stdin is a TTY
22
+ if (process.stdin.isTTY && process.stdin.isRaw) {
23
+ try {
24
+ process.stdin.setRawMode(false);
25
+ }
26
+ catch {
27
+ // Ignore errors - stdin may already be closed
28
+ }
29
+ }
30
+ }
31
+ /**
32
+ * Internal React Error Boundary class that ensures terminal cleanup on crashes.
33
+ * For OpenTUI, we use a simple fallback since OpenTUI components may not
34
+ * render correctly after an error.
35
+ */
36
+ class ErrorBoundaryClass extends React.Component {
37
+ constructor(props) {
38
+ super(props);
39
+ this.state = { hasError: false, error: null };
40
+ }
41
+ static getDerivedStateFromError(error) {
42
+ return { hasError: true, error };
43
+ }
44
+ componentDidCatch(error, errorInfo) {
45
+ // Restore terminal state immediately
46
+ restoreTerminal();
47
+ // Log error details for debugging
48
+ console.error('\n[TabsUI] Fatal error caught by error boundary:');
49
+ console.error(error);
50
+ if (errorInfo.componentStack) {
51
+ console.error('\nComponent stack:', errorInfo.componentStack);
52
+ }
53
+ // Notify parent if callback provided
54
+ this.props.onFatalError?.(error, errorInfo);
55
+ }
56
+ render() {
57
+ if (this.state.hasError) {
58
+ // For OpenTUI, render a simple box with the error message
59
+ // The terminal should already be restored at this point
60
+ return (_jsxs("box", { flexDirection: "column", padding: 1, children: [_jsx("text", { fg: "#FF0000", children: "TabsUI encountered a fatal error" }), _jsx("text", { fg: "#888888", children: this.state.error?.message ?? 'Unknown error' }), _jsx("text", { fg: "#666666", children: "Press Ctrl+C to exit" })] }));
61
+ }
62
+ return this.props.children;
63
+ }
64
+ }
65
+ /**
66
+ * Export the error boundary class.
67
+ * Note: When using in JSX with OpenTUI, you may need to use React.createElement directly
68
+ * due to OpenTUI's JSX type constraints.
69
+ */
70
+ export const TabsErrorBoundary = ErrorBoundaryClass;
@@ -0,0 +1,14 @@
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
+ import type { EventBus } from '@pokit/core';
8
+ type EventDrivenAppProps = {
9
+ bus: EventBus;
10
+ onExit: (code: number) => void;
11
+ };
12
+ export declare function EventDrivenApp({ bus, onExit }: EventDrivenAppProps): React.ReactNode;
13
+ export {};
14
+ //# sourceMappingURL=event-driven-app.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-driven-app.d.ts","sourceRoot":"","sources":["../src/event-driven-app.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAK5C,KAAK,mBAAmB,GAAG;IACzB,GAAG,EAAE,QAAQ,CAAC;IACd,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC,CAAC;AAwHF,wBAAgB,cAAc,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,mBAAmB,GAAG,KAAK,CAAC,SAAS,CAkGpF"}
@@ -0,0 +1,129 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ /**
3
+ * Event-Driven CLI App for OpenTUI
4
+ *
5
+ * A React component that renders CLI state based on EventBus events.
6
+ * Supports different layouts (tabs, parallel, sequence) based on group layout hints.
7
+ */
8
+ import { useState, useCallback } from 'react';
9
+ import { useKeyboard, useTerminalDimensions } from '@opentui/react';
10
+ import { findTabsGroup, getTabsGroupActivities } from '@pokit/tabs-core';
11
+ import { useEventBus } from './use-event-bus.js';
12
+ function getStatusIndicator({ status, isActive, }) {
13
+ switch (status) {
14
+ case 'running':
15
+ return { color: isActive ? '#00FFFF' : '#008B8B', icon: '\u25CF' };
16
+ case 'success':
17
+ return { color: isActive ? '#00FF00' : '#008000', icon: '\u2713' };
18
+ case 'failure':
19
+ return { color: isActive ? '#FF0000' : '#8B0000', icon: '\u2717' };
20
+ }
21
+ }
22
+ function ActivityTabBar({ activities, activeIndex, }) {
23
+ return (_jsx("box", { flexDirection: "row", gap: 1, flexWrap: "wrap", children: activities.map((activity, i) => {
24
+ const isActive = i === activeIndex;
25
+ const { color, icon } = getStatusIndicator({
26
+ status: activity.status,
27
+ isActive,
28
+ });
29
+ return (_jsxs("box", { flexDirection: "row", children: [_jsxs("text", { fg: color, children: [" ", icon, " "] }), _jsx("box", { style: isActive ? { backgroundColor: '#444' } : {}, children: _jsxs("text", { fg: isActive ? '#FFF' : '#888', children: [' ', activity.label, " (", i + 1, ")", ' '] }) })] }, activity.id));
30
+ }) }));
31
+ }
32
+ function ActivityView({ activity, viewHeight }) {
33
+ const logs = activity.logs.slice(-viewHeight);
34
+ return (_jsxs("box", { flexDirection: "column", height: viewHeight, overflow: "hidden", children: [activity.message && _jsx("text", { fg: "#00FFFF", children: activity.message }), activity.progress !== undefined && _jsxs("text", { fg: "#FFFF00", children: ["Progress: ", activity.progress, "%"] }), logs.map((log, i) => {
35
+ let color;
36
+ switch (log.level) {
37
+ case 'error':
38
+ color = '#FF0000';
39
+ break;
40
+ case 'warn':
41
+ color = '#FFFF00';
42
+ break;
43
+ case 'success':
44
+ color = '#00FF00';
45
+ break;
46
+ case 'info':
47
+ color = '#0000FF';
48
+ break;
49
+ default:
50
+ color = undefined;
51
+ }
52
+ return (_jsx("text", { fg: color, children: log.message }, i));
53
+ }), Array.from({
54
+ length: Math.max(0, viewHeight -
55
+ logs.length -
56
+ (activity.message ? 1 : 0) -
57
+ (activity.progress !== undefined ? 1 : 0)),
58
+ }).map((_, i) => (_jsx("text", { children: " " }, `empty-${i}`)))] }));
59
+ }
60
+ function StatusBar({ activityCount, quitConfirmPending, }) {
61
+ if (quitConfirmPending) {
62
+ return (_jsx("box", { children: _jsx("box", { style: { backgroundColor: '#FFFF00' }, children: _jsx("text", { fg: "#000000", children: " Press q again to quit, any other key to cancel " }) }) }));
63
+ }
64
+ return (_jsx("box", { children: _jsxs("text", { fg: "#666666", children: ["[Tab/1-", activityCount, "] switch | [q]uit"] }) }));
65
+ }
66
+ export function EventDrivenApp({ bus, onExit }) {
67
+ const state = useEventBus(bus);
68
+ const { height: rows } = useTerminalDimensions();
69
+ const [activeIndex, setActiveIndex] = useState(0);
70
+ const [quitConfirmPending, setQuitConfirmPending] = useState(false);
71
+ const terminalHeight = rows ?? 24;
72
+ const viewHeight = Math.max(5, terminalHeight - 6);
73
+ const tabsGroup = findTabsGroup(state);
74
+ const activities = tabsGroup
75
+ ? getTabsGroupActivities(state, tabsGroup.id)
76
+ : Array.from(state.activities.values());
77
+ const activeActivity = activities[activeIndex];
78
+ const switchTab = useCallback((newIndex) => {
79
+ if (newIndex >= 0 && newIndex < activities.length) {
80
+ setActiveIndex(newIndex);
81
+ }
82
+ }, [activities.length]);
83
+ const handleQuitRequest = useCallback(() => {
84
+ setQuitConfirmPending((prev) => !prev);
85
+ }, []);
86
+ const handleQuit = useCallback(() => {
87
+ onExit(state.exitCode ?? 0);
88
+ }, [onExit, state.exitCode]);
89
+ useKeyboard((event) => {
90
+ const { name, ctrl, shift } = event;
91
+ if (quitConfirmPending) {
92
+ if (name === 'q') {
93
+ handleQuit();
94
+ }
95
+ else {
96
+ handleQuitRequest();
97
+ }
98
+ return;
99
+ }
100
+ if (name === 'q') {
101
+ handleQuitRequest();
102
+ return;
103
+ }
104
+ if (name === 'c' && ctrl) {
105
+ handleQuit();
106
+ return;
107
+ }
108
+ const num = parseInt(name, 10);
109
+ if (num >= 1 && num <= activities.length) {
110
+ switchTab(num - 1);
111
+ return;
112
+ }
113
+ if (name === 'tab' && shift) {
114
+ switchTab((activeIndex - 1 + activities.length) % activities.length);
115
+ return;
116
+ }
117
+ if (name === 'tab') {
118
+ switchTab((activeIndex + 1) % activities.length);
119
+ return;
120
+ }
121
+ });
122
+ if (activities.length === 0) {
123
+ return (_jsxs("box", { flexDirection: "column", padding: 1, children: [_jsx("text", { children: "Waiting for activities..." }), state.appName && (_jsxs("text", { fg: "#666666", children: [state.appName, state.version ? ` v${state.version}` : ''] }))] }));
124
+ }
125
+ if (!activeActivity) {
126
+ return _jsx("text", { children: "No active activity" });
127
+ }
128
+ return (_jsxs("box", { flexDirection: "column", padding: 1, children: [_jsx(ActivityTabBar, { activities: activities, activeIndex: activeIndex }), _jsx("box", { border: ['top', 'bottom'], borderStyle: "single", borderColor: "#666666", children: _jsx(ActivityView, { activity: activeActivity, viewHeight: viewHeight }) }), _jsx(StatusBar, { activityCount: activities.length, quitConfirmPending: quitConfirmPending })] }));
129
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Help Overlay for OpenTUI
3
+ *
4
+ * Displays keyboard shortcuts in a modal overlay.
5
+ */
6
+ export type HelpOverlayProps = {
7
+ onClose: () => void;
8
+ };
9
+ export declare function HelpOverlay(_props: HelpOverlayProps): React.ReactNode;
10
+ //# sourceMappingURL=help-overlay.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"help-overlay.d.ts","sourceRoot":"","sources":["../src/help-overlay.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,CAAC;AAEF,wBAAgB,WAAW,CAAC,MAAM,EAAE,gBAAgB,GAAG,KAAK,CAAC,SAAS,CAoCrE"}
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ /**
3
+ * Help Overlay for OpenTUI
4
+ *
5
+ * Displays keyboard shortcuts in a modal overlay.
6
+ */
7
+ import { HELP_CONTENT } from '@pokit/tabs-core';
8
+ export function HelpOverlay(_props) {
9
+ return (_jsxs("box", { flexDirection: "column", border: ['top', 'bottom', 'left', 'right'], borderStyle: "single", borderColor: "#0000FF", padding: 1, children: [_jsx("box", { justifyContent: "center", marginBottom: 1, children: _jsx("text", { fg: "#FFFFFF", children: "Keyboard Help" }) }), HELP_CONTENT.map((group, i) => (_jsxs("box", { flexDirection: "column", marginBottom: i < HELP_CONTENT.length - 1 ? 1 : 0, children: [_jsx("text", { fg: "#00FFFF", children: group.title }), group.shortcuts.map(({ key, description }) => (_jsxs("box", { flexDirection: "row", children: [_jsx("box", { width: 16, children: _jsx("text", { fg: "#FFFF00", children: key }) }), _jsx("text", { children: description })] }, key)))] }, group.title))), _jsx("box", { marginTop: 1, justifyContent: "center", children: _jsx("text", { fg: "#666666", children: "Press ? or Escape to close" }) })] }));
10
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @pokit/tabs-opentui
3
+ *
4
+ * OpenTUI-based tabs adapter for CLI applications.
5
+ * Implements the TabsAdapter interface using OpenTUI (React for CLI).
6
+ */
7
+ export { createTabsAdapter, createEventAdapter } from './adapter.js';
8
+ export type { EventAdapterOptions } from './adapter.js';
9
+ export type { ScrollBoxRenderable } from '@opentui/core';
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AACrE,YAAY,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AACxD,YAAY,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @pokit/tabs-opentui
3
+ *
4
+ * OpenTUI-based tabs adapter for CLI applications.
5
+ * Implements the TabsAdapter interface using OpenTUI (React for CLI).
6
+ */
7
+ export { createTabsAdapter, createEventAdapter } from './adapter.js';
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Tabbed View Components for OpenTUI
3
+ *
4
+ * UI components for the tabbed terminal interface using OpenTUI primitives.
5
+ */
6
+ import type { ScrollBoxRenderable } from '@opentui/core';
7
+ import type { TabProcess } from '@pokit/tabs-core';
8
+ type TabbedViewProps = {
9
+ tabs: TabProcess[];
10
+ activeIndex: number;
11
+ onActiveIndexChange: (index: number) => void;
12
+ onQuit: () => void;
13
+ onQuitRequest: () => void;
14
+ onRestart: (index: number) => void;
15
+ onKill: (index: number) => void;
16
+ quitConfirmPending: boolean;
17
+ focusMode: boolean;
18
+ onEnterFocusMode: () => void;
19
+ onExitFocusMode: () => void;
20
+ onSendInput: (data: string) => void;
21
+ scrollRef?: (ref: ScrollBoxRenderable | null) => void;
22
+ helpVisible: boolean;
23
+ onToggleHelp: () => void;
24
+ onCloseHelp: () => void;
25
+ };
26
+ export declare function TabbedView({ tabs, activeIndex, onActiveIndexChange, onQuit, onQuitRequest, onRestart, onKill, quitConfirmPending, focusMode, onEnterFocusMode, onExitFocusMode, onSendInput, scrollRef, helpVisible, onToggleHelp, onCloseHelp, }: TabbedViewProps): React.ReactNode;
27
+ export {};
28
+ //# sourceMappingURL=tabbed-view.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tabbed-view.d.ts","sourceRoot":"","sources":["../src/tabbed-view.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EAAY,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACnE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAWnD,KAAK,eAAe,GAAG;IACrB,IAAI,EAAE,UAAU,EAAE,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,mBAAmB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7C,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,kBAAkB,EAAE,OAAO,CAAC;IAC5B,SAAS,EAAE,OAAO,CAAC;IACnB,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,eAAe,EAAE,MAAM,IAAI,CAAC;IAC5B,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,mBAAmB,GAAG,IAAI,KAAK,IAAI,CAAC;IACtD,WAAW,EAAE,OAAO,CAAC;IACrB,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB,WAAW,EAAE,MAAM,IAAI,CAAC;CACzB,CAAC;AA6JF,wBAAgB,UAAU,CAAC,EACzB,IAAI,EACJ,WAAW,EACX,mBAAmB,EACnB,MAAM,EACN,aAAa,EACb,SAAS,EACT,MAAM,EACN,kBAAkB,EAClB,SAAS,EACT,gBAAgB,EAChB,eAAe,EACf,WAAW,EACX,SAAS,EACT,WAAW,EACX,YAAY,EACZ,WAAW,GACZ,EAAE,eAAe,GAAG,KAAK,CAAC,SAAS,CA+FnC"}
@@ -0,0 +1,129 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ /**
3
+ * Tabbed View Components for OpenTUI
4
+ *
5
+ * UI components for the tabbed terminal interface using OpenTUI primitives.
6
+ */
7
+ import { useKeyboard, useTerminalDimensions } from '@opentui/react';
8
+ import { useTabsState, useKeyboardCallbackRefs, processKeyEvent, executeKeyboardAction, } from '@pokit/tabs-core';
9
+ import { HelpOverlay } from './help-overlay.js';
10
+ function getStatusIndicator({ status, isActive, }) {
11
+ switch (status) {
12
+ case 'running':
13
+ return { color: isActive ? '#00FFFF' : '#008B8B', icon: '\u25CF' };
14
+ case 'done':
15
+ return { color: isActive ? '#00FF00' : '#008000', icon: '\u2713' };
16
+ case 'error':
17
+ return { color: isActive ? '#FF0000' : '#8B0000', icon: '\u2717' };
18
+ case 'stopped':
19
+ return { color: isActive ? '#FFFF00' : '#808000', icon: '\u25A0' };
20
+ }
21
+ }
22
+ function TabBar({ tabs, activeIndex, focusMode, }) {
23
+ return (_jsxs("box", { flexDirection: "row", gap: 1, flexWrap: "wrap", children: [tabs.map((tab, i) => {
24
+ const isActive = i === activeIndex;
25
+ const { color, icon } = getStatusIndicator({
26
+ status: tab.status,
27
+ isActive,
28
+ });
29
+ return (_jsxs("box", { flexDirection: "row", children: [_jsxs("text", { fg: color, children: [" ", icon, " "] }), _jsx("box", { style: isActive ? { backgroundColor: '#444' } : {}, children: _jsxs("text", { fg: isActive ? '#FFF' : '#888', children: [' ', tab.label, " (", i + 1, ")", ' '] }) })] }, tab.id));
30
+ }), focusMode && (_jsx("box", { style: { backgroundColor: '#FFFF00' }, children: _jsx("text", { fg: "#000000", children: " INPUT MODE " }) }))] }));
31
+ }
32
+ function OutputView({ lines, viewHeight, isActive, scrollRef, }) {
33
+ return (_jsx("scrollbox", { ref: scrollRef, height: viewHeight, scrollY: true, focused: isActive, viewportCulling: false, children: lines.map((line, i) => (_jsx("text", { children: line || ' ' }, i))) }));
34
+ }
35
+ function StatusBar({ tabCount, quitConfirmPending, focusMode, showHelpHint, }) {
36
+ if (quitConfirmPending) {
37
+ return (_jsx("box", { children: _jsx("box", { style: { backgroundColor: '#FFFF00' }, children: _jsx("text", { fg: "#000000", children: " Press q again to quit, any other key to cancel " }) }) }));
38
+ }
39
+ if (focusMode) {
40
+ return (_jsxs("box", { flexDirection: "row", children: [_jsx("box", { style: { backgroundColor: '#00FFFF' }, children: _jsx("text", { fg: "#000000", children: " INPUT MODE " }) }), _jsx("text", { fg: "#666666", children: " Press Esc to exit input mode" })] }));
41
+ }
42
+ return (_jsx("box", { children: _jsxs("text", { fg: "#666666", children: ["[", '\u2191\u2193', "] scroll | [Tab/1-", tabCount, "] switch | [i]nput | [r]estart | [k]ill | [q]uit", showHelpHint && ' | Press ? for help'] }) }));
43
+ }
44
+ /**
45
+ * Normalize OpenTUI's KeyEvent to the shared NormalizedKeyEvent format.
46
+ */
47
+ function normalizeOpenTUIKeyEvent(event) {
48
+ const { name, ctrl, shift, meta, sequence } = event;
49
+ // Map OpenTUI key names to normalized names
50
+ let normalizedName;
51
+ if (name === 'escape')
52
+ normalizedName = 'escape';
53
+ else if (name === 'return')
54
+ normalizedName = 'return';
55
+ else if (name === 'tab')
56
+ normalizedName = 'tab';
57
+ else if (name === 'backspace')
58
+ normalizedName = 'backspace';
59
+ else if (name === 'delete')
60
+ normalizedName = 'delete';
61
+ else if (name === 'up')
62
+ normalizedName = 'up';
63
+ else if (name === 'down')
64
+ normalizedName = 'down';
65
+ else if (name === 'left')
66
+ normalizedName = 'left';
67
+ else if (name === 'right')
68
+ normalizedName = 'right';
69
+ else if (name === 'pageup')
70
+ normalizedName = 'pageup';
71
+ else if (name === 'pagedown')
72
+ normalizedName = 'pagedown';
73
+ else
74
+ normalizedName = name;
75
+ return {
76
+ char: sequence || undefined,
77
+ name: normalizedName,
78
+ ctrl,
79
+ shift,
80
+ meta,
81
+ };
82
+ }
83
+ export function TabbedView({ tabs, activeIndex, onActiveIndexChange, onQuit, onQuitRequest, onRestart, onKill, quitConfirmPending, focusMode, onEnterFocusMode, onExitFocusMode, onSendInput, scrollRef, helpVisible, onToggleHelp, onCloseHelp, }) {
84
+ const { height: rows } = useTerminalDimensions();
85
+ const terminalHeight = rows ?? 24;
86
+ const viewHeight = Math.max(5, terminalHeight - 6);
87
+ // Use shared tabs state hook
88
+ // Note: OpenTUI's scrollbox handles scrolling natively, so we don't use scrollBy here
89
+ const { showHelpHint, switchTab, nextTab, prevTab, scrollBy } = useTabsState({
90
+ tabs,
91
+ activeIndex,
92
+ onActiveIndexChange,
93
+ viewHeight,
94
+ });
95
+ // Use shared callback refs hook
96
+ const callbacks = {
97
+ onQuit,
98
+ onQuitRequest,
99
+ onRestart,
100
+ onKill,
101
+ onEnterFocusMode,
102
+ onExitFocusMode,
103
+ onSendInput,
104
+ onToggleHelp,
105
+ onCloseHelp,
106
+ };
107
+ const callbackRefs = useKeyboardCallbackRefs(callbacks);
108
+ useKeyboard((event) => {
109
+ const normalizedEvent = normalizeOpenTUIKeyEvent(event);
110
+ const action = processKeyEvent(normalizedEvent, {
111
+ helpVisible,
112
+ focusMode,
113
+ quitConfirmPending,
114
+ activeIndex,
115
+ tabCount: tabs.length,
116
+ }, viewHeight);
117
+ // For OpenTUI, we skip scroll actions since native scrollbox handles them
118
+ if (action.type === 'scroll') {
119
+ // OpenTUI's scrollbox handles up/down/pageup/pagedown natively
120
+ return;
121
+ }
122
+ executeKeyboardAction(action, callbackRefs, scrollBy, switchTab, nextTab, prevTab);
123
+ });
124
+ const activeTab = tabs[activeIndex];
125
+ if (!activeTab) {
126
+ return _jsx("text", { children: "No tabs available" });
127
+ }
128
+ return (_jsxs("box", { flexDirection: "column", padding: 1, children: [_jsx(TabBar, { tabs: tabs, activeIndex: activeIndex, focusMode: focusMode }), _jsx("box", { border: ['top', 'bottom'], borderStyle: "single", borderColor: "#666666", height: viewHeight + 2, children: _jsx(OutputView, { lines: activeTab.output, viewHeight: viewHeight, isActive: true, scrollRef: scrollRef }) }), _jsx(StatusBar, { tabCount: tabs.length, quitConfirmPending: quitConfirmPending, focusMode: focusMode, showHelpHint: showHelpHint }), helpVisible && (_jsx("box", { position: "absolute", flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: _jsx(HelpOverlay, { onClose: onCloseHelp }) }))] }));
129
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Tabs App for OpenTUI
3
+ *
4
+ * Wires ProcessManager from core to React state.
5
+ */
6
+ import type { TabSpec } from '@pokit/tabs-core';
7
+ import type { TabsOptions } from '@pokit/core';
8
+ type TabsAppProps = {
9
+ items: TabSpec[];
10
+ options: TabsOptions;
11
+ onExit: (code: number) => void;
12
+ };
13
+ export declare function TabsApp({ items, options, onExit }: TabsAppProps): React.ReactNode;
14
+ export {};
15
+ //# sourceMappingURL=tabs-app.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tabs-app.d.ts","sourceRoot":"","sources":["../src/tabs-app.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAYH,OAAO,KAAK,EAAE,OAAO,EAAc,MAAM,kBAAkB,CAAC;AAC5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAS/C,KAAK,YAAY,GAAG;IAClB,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,OAAO,EAAE,WAAW,CAAC;IACrB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC,CAAC;AAOF,wBAAgB,OAAO,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,YAAY,GAAG,KAAK,CAAC,SAAS,CAiWjF"}
@@ -0,0 +1,296 @@
1
+ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ /**
3
+ * Tabs App for OpenTUI
4
+ *
5
+ * Wires ProcessManager from core to React state.
6
+ */
7
+ import { useState, useEffect, useCallback, useRef } from 'react';
8
+ import { spawn } from 'node:child_process';
9
+ import stripAnsi from 'strip-ansi';
10
+ import { TabbedView } from './tabbed-view.js';
11
+ import { OutputBuffer, MAX_OUTPUT_LINES, MAX_LINE_LENGTH, BUFFER_WARNING_THRESHOLD, } from '@pokit/tabs-core';
12
+ const OUTPUT_BATCH_MS = 16;
13
+ /** How many pixels from bottom to consider "near bottom" for auto-scroll */
14
+ const NEAR_BOTTOM_THRESHOLD = 50;
15
+ /** Delay before scrolling to allow React to render new content */
16
+ const SCROLL_DELAY_MS = 50;
17
+ export function TabsApp({ items, options, onExit }) {
18
+ // Ring buffers for storing all output per tab (with O(1) operations)
19
+ const ringBuffersRef = useRef(new Map());
20
+ // Initialize ring buffers for each tab
21
+ const getOrCreateRingBuffer = useCallback((tabId) => {
22
+ let buffer = ringBuffersRef.current.get(tabId);
23
+ if (!buffer) {
24
+ buffer = new OutputBuffer({
25
+ maxLines: MAX_OUTPUT_LINES,
26
+ maxLineLength: MAX_LINE_LENGTH,
27
+ warnAtPercentage: BUFFER_WARNING_THRESHOLD,
28
+ tabId,
29
+ // Optional: could add onPressure callback here for UI feedback
30
+ });
31
+ ringBuffersRef.current.set(tabId, buffer);
32
+ }
33
+ return buffer;
34
+ }, []);
35
+ const [tabs, setTabs] = useState(() => items.map((item, i) => ({
36
+ id: `tab-${i}`,
37
+ label: item.label,
38
+ exec: item.exec,
39
+ output: [],
40
+ status: 'running',
41
+ })));
42
+ const [quitConfirmPending, setQuitConfirmPending] = useState(false);
43
+ const [focusMode, setFocusMode] = useState(false);
44
+ const [activeIndex, setActiveIndex] = useState(0);
45
+ const [helpVisible, setHelpVisible] = useState(false);
46
+ const processesRef = useRef([]);
47
+ const batchBuffersRef = useRef(new Map());
48
+ const flushScheduledRef = useRef(false);
49
+ const scrollBoxRef = useRef(null);
50
+ /**
51
+ * Check if the scrollbox is near the bottom.
52
+ * Returns true if within NEAR_BOTTOM_THRESHOLD pixels of bottom, or if content fits in viewport.
53
+ */
54
+ const checkIfNearBottom = useCallback(() => {
55
+ const scroll = scrollBoxRef.current;
56
+ if (!scroll)
57
+ return true;
58
+ const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.viewport.height);
59
+ if (maxScrollTop <= 0)
60
+ return true; // Content fits in viewport
61
+ const distanceFromBottom = maxScrollTop - scroll.scrollTop;
62
+ return distanceFromBottom <= NEAR_BOTTOM_THRESHOLD;
63
+ }, []);
64
+ /**
65
+ * Scroll to bottom immediately (synchronous). Used for tab switches to avoid visible snap.
66
+ */
67
+ const scrollToBottomImmediate = useCallback(() => {
68
+ const scroll = scrollBoxRef.current;
69
+ if (scroll) {
70
+ scroll.scrollTo(scroll.scrollHeight);
71
+ }
72
+ }, []);
73
+ /**
74
+ * Scroll to bottom with delay. Called after content updates to let React render first.
75
+ */
76
+ const scrollToBottom = useCallback(() => {
77
+ setTimeout(() => {
78
+ scrollToBottomImmediate();
79
+ }, SCROLL_DELAY_MS);
80
+ }, [scrollToBottomImmediate]);
81
+ const handleScrollRef = useCallback((ref) => {
82
+ scrollBoxRef.current = ref;
83
+ // Initial scroll to bottom when ref is set - immediate to avoid snap
84
+ if (ref) {
85
+ scrollToBottomImmediate();
86
+ }
87
+ }, [scrollToBottomImmediate]);
88
+ const flushOutput = useCallback(() => {
89
+ flushScheduledRef.current = false;
90
+ const buffersSnapshot = batchBuffersRef.current;
91
+ batchBuffersRef.current = new Map();
92
+ if (buffersSnapshot.size === 0)
93
+ return;
94
+ const updates = [];
95
+ for (const [index, buffer] of buffersSnapshot.entries()) {
96
+ if (buffer.lines.length > 0) {
97
+ updates.push({ index, lines: buffer.lines });
98
+ }
99
+ }
100
+ if (updates.length === 0)
101
+ return;
102
+ // Check if near bottom BEFORE updating state (while old content is rendered)
103
+ const wasNearBottom = checkIfNearBottom();
104
+ setTabs((prev) => {
105
+ const next = [...prev];
106
+ for (const { index, lines } of updates) {
107
+ const tab = next[index];
108
+ if (!tab)
109
+ continue;
110
+ // Push to ring buffer and get output with dropped indicator
111
+ const ringBuffer = getOrCreateRingBuffer(tab.id);
112
+ ringBuffer.push(...lines);
113
+ next[index] = { ...tab, output: ringBuffer.toArray() };
114
+ }
115
+ return next;
116
+ });
117
+ // If we were near bottom, scroll to bottom after content updates
118
+ if (wasNearBottom) {
119
+ scrollToBottom();
120
+ }
121
+ }, [checkIfNearBottom, scrollToBottom, getOrCreateRingBuffer]);
122
+ const scheduleFlush = useCallback(() => {
123
+ if (flushScheduledRef.current)
124
+ return;
125
+ flushScheduledRef.current = true;
126
+ setTimeout(flushOutput, OUTPUT_BATCH_MS);
127
+ }, [flushOutput]);
128
+ const appendOutput = useCallback((index, data) => {
129
+ const rawText = data.toString('utf-8');
130
+ // Strip ALL ANSI sequences to prevent rendering artifacts
131
+ // We lose colors but gain reliable scrolling
132
+ const text = stripAnsi(rawText)
133
+ // Also convert lone carriage returns to newlines (progress bars)
134
+ .replace(/\r(?!\n)/g, '\n');
135
+ const lines = text.split(/\r?\n/).filter((line, idx, arr) => {
136
+ return line || idx < arr.length - 1;
137
+ });
138
+ if (lines.length === 0)
139
+ return;
140
+ let buffer = batchBuffersRef.current.get(index);
141
+ if (!buffer) {
142
+ buffer = { lines: [] };
143
+ batchBuffersRef.current.set(index, buffer);
144
+ }
145
+ buffer.lines.push(...lines);
146
+ scheduleFlush();
147
+ }, [scheduleFlush]);
148
+ const spawnProcess = useCallback((index) => {
149
+ const item = items[index];
150
+ if (!item)
151
+ return null;
152
+ const proc = spawn('sh', ['-c', item.exec], {
153
+ cwd: options.cwd,
154
+ env: {
155
+ ...options.env,
156
+ FORCE_COLOR: '1',
157
+ },
158
+ stdio: ['pipe', 'pipe', 'pipe'],
159
+ });
160
+ const handleData = (data) => appendOutput(index, data);
161
+ proc.stdout?.on('data', handleData);
162
+ proc.stderr?.on('data', handleData);
163
+ proc.on('close', (code) => {
164
+ flushOutput();
165
+ setTabs((prev) => {
166
+ const next = [...prev];
167
+ const tab = next[index];
168
+ if (!tab)
169
+ return prev;
170
+ next[index] = {
171
+ ...tab,
172
+ status: code === 0 ? 'done' : 'error',
173
+ exitCode: code ?? undefined,
174
+ };
175
+ return next;
176
+ });
177
+ });
178
+ proc.on('error', (err) => {
179
+ setTabs((prev) => {
180
+ const next = [...prev];
181
+ const tab = next[index];
182
+ if (!tab)
183
+ return prev;
184
+ next[index] = {
185
+ ...tab,
186
+ status: 'error',
187
+ output: [...tab.output, `Error: ${err.message}`],
188
+ };
189
+ return next;
190
+ });
191
+ });
192
+ return proc;
193
+ }, [items, options.cwd, options.env, appendOutput, flushOutput]);
194
+ useEffect(() => {
195
+ const procs = items.map((_, i) => spawnProcess(i));
196
+ processesRef.current = procs;
197
+ return () => {
198
+ for (const proc of processesRef.current) {
199
+ if (proc && !proc.killed) {
200
+ proc.kill('SIGTERM');
201
+ }
202
+ }
203
+ };
204
+ }, [items, spawnProcess]);
205
+ const killAll = useCallback(() => {
206
+ for (const proc of processesRef.current) {
207
+ if (proc && !proc.killed) {
208
+ proc.kill('SIGTERM');
209
+ }
210
+ }
211
+ }, []);
212
+ const handleRestart = useCallback((index) => {
213
+ const existingProc = processesRef.current[index];
214
+ if (existingProc && !existingProc.killed) {
215
+ existingProc.kill('SIGTERM');
216
+ }
217
+ // Clear both batch buffer and ring buffer
218
+ batchBuffersRef.current.delete(index);
219
+ const tabId = `tab-${index}`;
220
+ ringBuffersRef.current.get(tabId)?.clear();
221
+ setTabs((prev) => {
222
+ const next = [...prev];
223
+ const tab = next[index];
224
+ if (!tab)
225
+ return prev;
226
+ next[index] = {
227
+ ...tab,
228
+ output: ['Restarting...', ''],
229
+ status: 'running',
230
+ exitCode: undefined,
231
+ };
232
+ return next;
233
+ });
234
+ // Scroll to bottom after restart since output is cleared
235
+ scrollToBottom();
236
+ setTimeout(() => {
237
+ const newProc = spawnProcess(index);
238
+ processesRef.current[index] = newProc;
239
+ }, 100);
240
+ }, [spawnProcess, scrollToBottom]);
241
+ const handleKill = useCallback((index) => {
242
+ const proc = processesRef.current[index];
243
+ if (proc && !proc.killed) {
244
+ proc.kill('SIGTERM');
245
+ }
246
+ setTabs((prev) => {
247
+ const next = [...prev];
248
+ const tab = next[index];
249
+ if (!tab)
250
+ return prev;
251
+ if (tab.status !== 'running')
252
+ return prev;
253
+ next[index] = {
254
+ ...tab,
255
+ status: 'stopped',
256
+ output: [...tab.output, '', 'Stopped'],
257
+ };
258
+ return next;
259
+ });
260
+ }, []);
261
+ const handleQuitRequest = useCallback(() => {
262
+ setQuitConfirmPending((prev) => !prev);
263
+ }, []);
264
+ const handleQuit = useCallback(() => {
265
+ killAll();
266
+ onExit(0);
267
+ }, [killAll, onExit]);
268
+ const handleEnterFocusMode = useCallback(() => {
269
+ const proc = processesRef.current[activeIndex];
270
+ if (proc && !proc.killed && proc.stdin) {
271
+ setFocusMode(true);
272
+ }
273
+ }, [activeIndex]);
274
+ const handleExitFocusMode = useCallback(() => {
275
+ setFocusMode(false);
276
+ }, []);
277
+ const handleSendInput = useCallback((data) => {
278
+ const proc = processesRef.current[activeIndex];
279
+ if (proc && !proc.killed && proc.stdin) {
280
+ proc.stdin.write(data);
281
+ }
282
+ }, [activeIndex]);
283
+ const handleActiveIndexChange = useCallback((index) => {
284
+ setActiveIndex(index);
285
+ setFocusMode(false);
286
+ // Scroll to bottom when switching tabs
287
+ scrollToBottom();
288
+ }, [scrollToBottom]);
289
+ const handleToggleHelp = useCallback(() => {
290
+ setHelpVisible((prev) => !prev);
291
+ }, []);
292
+ const handleCloseHelp = useCallback(() => {
293
+ setHelpVisible(false);
294
+ }, []);
295
+ return (_jsx(TabbedView, { tabs: tabs, activeIndex: activeIndex, onActiveIndexChange: handleActiveIndexChange, onQuit: handleQuit, onQuitRequest: handleQuitRequest, onRestart: handleRestart, onKill: handleKill, quitConfirmPending: quitConfirmPending, focusMode: focusMode, onEnterFocusMode: handleEnterFocusMode, onExitFocusMode: handleExitFocusMode, onSendInput: handleSendInput, scrollRef: handleScrollRef, helpVisible: helpVisible, onToggleHelp: handleToggleHelp, onCloseHelp: handleCloseHelp }));
296
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * React Hook for EventBus
3
+ *
4
+ * Provides a hook to subscribe to EventBus events and manage state.
5
+ */
6
+ import type { EventBus } from '@pokit/core';
7
+ import type { EventDrivenState } from '@pokit/tabs-core';
8
+ /**
9
+ * Hook to subscribe to EventBus and manage state
10
+ */
11
+ export declare function useEventBus(bus: EventBus): EventDrivenState;
12
+ //# sourceMappingURL=use-event-bus.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-event-bus.d.ts","sourceRoot":"","sources":["../src/use-event-bus.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAY,MAAM,aAAa,CAAC;AACtD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAGzD;;GAEG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,QAAQ,GAAG,gBAAgB,CAa3D"}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * React Hook for EventBus
3
+ *
4
+ * Provides a hook to subscribe to EventBus events and manage state.
5
+ */
6
+ import { useState, useEffect, useCallback } from 'react';
7
+ import { createInitialState, reducer } from '@pokit/tabs-core';
8
+ /**
9
+ * Hook to subscribe to EventBus and manage state
10
+ */
11
+ export function useEventBus(bus) {
12
+ const [state, setState] = useState(createInitialState);
13
+ const handleEvent = useCallback((event) => {
14
+ setState((prevState) => reducer(prevState, event));
15
+ }, []);
16
+ useEffect(() => {
17
+ const unsubscribe = bus.on(handleEvent);
18
+ return unsubscribe;
19
+ }, [bus, handleEvent]);
20
+ return state;
21
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@pokit/tabs-opentui",
3
+ "version": "0.0.1",
4
+ "description": "OpenTUI-based tab renderer for pok CLI applications",
5
+ "keywords": [
6
+ "cli",
7
+ "command-line",
8
+ "typescript",
9
+ "bun",
10
+ "terminal",
11
+ "pok",
12
+ "pokjs",
13
+ "tabs",
14
+ "opentui",
15
+ "react",
16
+ "tui"
17
+ ],
18
+ "type": "module",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/notation-dev/openpok.git",
23
+ "directory": "packages/tabs-opentui"
24
+ },
25
+ "homepage": "https://github.com/notation-dev/openpok#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/notation-dev/openpok/issues"
28
+ },
29
+ "main": "./src/index.ts",
30
+ "module": "./src/index.ts",
31
+ "types": "./src/index.ts",
32
+ "exports": {
33
+ ".": {
34
+ "bun": "./src/index.ts",
35
+ "types": "./dist/index.d.ts",
36
+ "import": "./dist/index.js"
37
+ }
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "dependencies": {
48
+ "@opentui/core": "^0.1.63",
49
+ "@opentui/react": "^0.1.63",
50
+ "react": "^19.2.0",
51
+ "strip-ansi": "^7.1.2"
52
+ },
53
+ "devDependencies": {
54
+ "@types/bun": "latest",
55
+ "@types/react": "^19.2.0",
56
+ "@pokit/core": "0.0.1",
57
+ "@pokit/tabs-core": "0.0.1"
58
+ },
59
+ "peerDependencies": {
60
+ "@pokit/core": "0.0.1",
61
+ "@pokit/tabs-core": "0.0.1"
62
+ },
63
+ "engines": {
64
+ "bun": ">=1.0.0"
65
+ }
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @pokit/tabs-opentui
3
+ *
4
+ * OpenTUI-based tabs adapter for CLI applications.
5
+ * Implements the TabsAdapter interface using OpenTUI (React for CLI).
6
+ */
7
+
8
+ export { createTabsAdapter, createEventAdapter } from './adapter.js';
9
+ export type { EventAdapterOptions } from './adapter.js';
10
+ export type { ScrollBoxRenderable } from '@opentui/core';