@pokit/tabs-core 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 +7 -7
- package/src/constants/help-content.ts +49 -0
- package/src/constants/index.ts +10 -0
- package/src/constants/keyboard.ts +37 -0
- package/src/hooks/index.ts +20 -0
- package/src/hooks/use-keyboard-handler.ts +318 -0
- package/src/hooks/use-tabs-state.ts +150 -0
- package/src/process-manager.ts +235 -0
- package/src/ring-buffer.ts +338 -0
- package/src/state-reducer.ts +206 -0
- package/src/types.ts +106 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pokit/tabs-core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "Core tab management utilities for pok CLI applications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -25,9 +25,8 @@
|
|
|
25
25
|
"bugs": {
|
|
26
26
|
"url": "https://github.com/notation-dev/openpok/issues"
|
|
27
27
|
},
|
|
28
|
-
"main": "./
|
|
29
|
-
"
|
|
30
|
-
"types": "./src/index.ts",
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
31
30
|
"exports": {
|
|
32
31
|
".": {
|
|
33
32
|
"bun": "./src/index.ts",
|
|
@@ -38,14 +37,15 @@
|
|
|
38
37
|
"files": [
|
|
39
38
|
"dist",
|
|
40
39
|
"README.md",
|
|
41
|
-
"LICENSE"
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"src"
|
|
42
42
|
],
|
|
43
43
|
"publishConfig": {
|
|
44
44
|
"access": "public"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"react": "^18.0.0 || ^19.0.0",
|
|
48
|
-
"@pokit/core": "0.0.
|
|
48
|
+
"@pokit/core": "0.0.2"
|
|
49
49
|
},
|
|
50
50
|
"peerDependenciesMeta": {
|
|
51
51
|
"react": {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"@types/bun": "latest",
|
|
57
57
|
"@types/react": "^19.2.0",
|
|
58
58
|
"react": "^19.2.0",
|
|
59
|
-
"@pokit/core": "0.0.
|
|
59
|
+
"@pokit/core": "0.0.2"
|
|
60
60
|
},
|
|
61
61
|
"engines": {
|
|
62
62
|
"bun": ">=1.0.0"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Help Content for CLI Tabs
|
|
3
|
+
*
|
|
4
|
+
* Keyboard shortcut definitions and help overlay content used by all adapters.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type Shortcut = {
|
|
8
|
+
key: string;
|
|
9
|
+
description: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type ShortcutGroup = {
|
|
13
|
+
title: string;
|
|
14
|
+
shortcuts: Shortcut[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Standard keyboard shortcut help content.
|
|
19
|
+
* Used by both Ink and OpenTUI help overlays.
|
|
20
|
+
*/
|
|
21
|
+
export const HELP_CONTENT: ShortcutGroup[] = [
|
|
22
|
+
{
|
|
23
|
+
title: 'Navigation',
|
|
24
|
+
shortcuts: [
|
|
25
|
+
{ key: '\u2191/\u2193', description: 'Scroll output up/down' },
|
|
26
|
+
{ key: 'Page Up/Dn', description: 'Scroll by page' },
|
|
27
|
+
{ key: 'Tab', description: 'Next tab' },
|
|
28
|
+
{ key: 'Shift+Tab', description: 'Previous tab' },
|
|
29
|
+
{ key: '1-9', description: 'Jump to tab by number' },
|
|
30
|
+
{ key: 'Meta+\u2190/\u2192', description: 'Previous/next tab' },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
title: 'Process Control',
|
|
35
|
+
shortcuts: [
|
|
36
|
+
{ key: 'r', description: 'Restart current process' },
|
|
37
|
+
{ key: 'k', description: 'Kill current process' },
|
|
38
|
+
{ key: 'q', description: 'Quit (with confirmation)' },
|
|
39
|
+
{ key: 'Ctrl+C', description: 'Force quit immediately' },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
title: 'Input Mode',
|
|
44
|
+
shortcuts: [
|
|
45
|
+
{ key: 'i', description: 'Enter input mode' },
|
|
46
|
+
{ key: 'Escape', description: 'Exit input mode' },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constants Index
|
|
3
|
+
*
|
|
4
|
+
* Re-exports all shared constants for CLI tabs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type { Shortcut, ShortcutGroup } from './help-content.js';
|
|
8
|
+
export { HELP_CONTENT } from './help-content.js';
|
|
9
|
+
|
|
10
|
+
export { KEY_SEQUENCES, ctrlKeyToSequence, HELP_HINT_DURATION_MS } from './keyboard.js';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard Constants for CLI Tabs
|
|
3
|
+
*
|
|
4
|
+
* Escape sequences and key mappings for terminal input handling.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Common terminal escape sequences for special keys.
|
|
9
|
+
* Used when forwarding input to child processes in focus mode.
|
|
10
|
+
*/
|
|
11
|
+
export const KEY_SEQUENCES = {
|
|
12
|
+
RETURN: '\n',
|
|
13
|
+
TAB: '\t',
|
|
14
|
+
BACKSPACE: '\x7f',
|
|
15
|
+
DELETE: '\x1b[3~',
|
|
16
|
+
ARROW_UP: '\x1b[A',
|
|
17
|
+
ARROW_DOWN: '\x1b[B',
|
|
18
|
+
ARROW_RIGHT: '\x1b[C',
|
|
19
|
+
ARROW_LEFT: '\x1b[D',
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert a Ctrl+key combination to its terminal escape sequence.
|
|
24
|
+
* Ctrl+A = 1, Ctrl+B = 2, ..., Ctrl+Z = 26
|
|
25
|
+
*/
|
|
26
|
+
export function ctrlKeyToSequence(key: string): string | null {
|
|
27
|
+
const code = key.toUpperCase().charCodeAt(0) - 64;
|
|
28
|
+
if (code >= 1 && code <= 26) {
|
|
29
|
+
return String.fromCharCode(code);
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Duration to show help hint on startup (in milliseconds).
|
|
36
|
+
*/
|
|
37
|
+
export const HELP_HINT_DURATION_MS = 5000;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hooks Index
|
|
3
|
+
*
|
|
4
|
+
* Re-exports all shared hooks for CLI tabs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type { UseTabsStateOptions, TabsState, TabsActions } from './use-tabs-state.js';
|
|
8
|
+
export { useTabsState } from './use-tabs-state.js';
|
|
9
|
+
|
|
10
|
+
export type {
|
|
11
|
+
KeyboardCallbacks,
|
|
12
|
+
KeyboardState,
|
|
13
|
+
NormalizedKeyEvent,
|
|
14
|
+
KeyboardAction,
|
|
15
|
+
} from './use-keyboard-handler.js';
|
|
16
|
+
export {
|
|
17
|
+
processKeyEvent,
|
|
18
|
+
useKeyboardCallbackRefs,
|
|
19
|
+
executeKeyboardAction,
|
|
20
|
+
} from './use-keyboard-handler.js';
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Keyboard Handler Logic
|
|
3
|
+
*
|
|
4
|
+
* Framework-agnostic keyboard action handling for tabbed terminal interfaces.
|
|
5
|
+
* Provides normalized keyboard actions that can be called by framework-specific handlers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useRef, useEffect } from 'react';
|
|
9
|
+
import { KEY_SEQUENCES, ctrlKeyToSequence } from '../constants/keyboard.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Callbacks for keyboard actions in the tabbed view.
|
|
13
|
+
*/
|
|
14
|
+
export type KeyboardCallbacks = {
|
|
15
|
+
onQuit: () => void;
|
|
16
|
+
onQuitRequest: () => void;
|
|
17
|
+
onRestart: (index: number) => void;
|
|
18
|
+
onKill: (index: number) => void;
|
|
19
|
+
onEnterFocusMode: () => void;
|
|
20
|
+
onExitFocusMode: () => void;
|
|
21
|
+
onSendInput: (data: string) => void;
|
|
22
|
+
onToggleHelp: () => void;
|
|
23
|
+
onCloseHelp: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Current state needed for keyboard handling decisions.
|
|
28
|
+
*/
|
|
29
|
+
export type KeyboardState = {
|
|
30
|
+
helpVisible: boolean;
|
|
31
|
+
focusMode: boolean;
|
|
32
|
+
quitConfirmPending: boolean;
|
|
33
|
+
activeIndex: number;
|
|
34
|
+
tabCount: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalized key event structure.
|
|
39
|
+
* Provides a unified interface for different terminal keyboard APIs.
|
|
40
|
+
*/
|
|
41
|
+
export type NormalizedKeyEvent = {
|
|
42
|
+
/** The character(s) typed, if any */
|
|
43
|
+
char?: string;
|
|
44
|
+
/** Named key (e.g., 'escape', 'tab', 'up', 'down') */
|
|
45
|
+
name?: string;
|
|
46
|
+
/** Whether Ctrl was held */
|
|
47
|
+
ctrl?: boolean;
|
|
48
|
+
/** Whether Shift was held */
|
|
49
|
+
shift?: boolean;
|
|
50
|
+
/** Whether Meta/Alt was held */
|
|
51
|
+
meta?: boolean;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Action results from keyboard handling.
|
|
56
|
+
*/
|
|
57
|
+
export type KeyboardAction =
|
|
58
|
+
| { type: 'toggle-help' }
|
|
59
|
+
| { type: 'close-help' }
|
|
60
|
+
| { type: 'exit-focus-mode' }
|
|
61
|
+
| { type: 'send-input'; data: string }
|
|
62
|
+
| { type: 'quit' }
|
|
63
|
+
| { type: 'quit-request' }
|
|
64
|
+
| { type: 'cancel-quit' }
|
|
65
|
+
| { type: 'enter-focus-mode' }
|
|
66
|
+
| { type: 'restart'; index: number }
|
|
67
|
+
| { type: 'kill'; index: number }
|
|
68
|
+
| { type: 'switch-tab'; index: number }
|
|
69
|
+
| { type: 'next-tab' }
|
|
70
|
+
| { type: 'prev-tab' }
|
|
71
|
+
| { type: 'scroll'; delta: number }
|
|
72
|
+
| { type: 'none' };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Process a normalized key event and return the appropriate action.
|
|
76
|
+
*
|
|
77
|
+
* This is the core keyboard handling logic, extracted to be framework-agnostic.
|
|
78
|
+
* Each adapter (Ink, OpenTUI) normalizes their keyboard events and calls this function.
|
|
79
|
+
*/
|
|
80
|
+
export function processKeyEvent(
|
|
81
|
+
event: NormalizedKeyEvent,
|
|
82
|
+
state: KeyboardState,
|
|
83
|
+
viewHeight: number
|
|
84
|
+
): KeyboardAction {
|
|
85
|
+
const { char, name, ctrl, shift, meta } = event;
|
|
86
|
+
const { helpVisible, focusMode, quitConfirmPending, activeIndex, tabCount } = state;
|
|
87
|
+
|
|
88
|
+
// Help toggle takes priority
|
|
89
|
+
if (char === '?') {
|
|
90
|
+
return { type: 'toggle-help' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Escape closes help if visible
|
|
94
|
+
if (name === 'escape' && helpVisible) {
|
|
95
|
+
return { type: 'close-help' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Don't process other keys when help is visible
|
|
99
|
+
if (helpVisible) {
|
|
100
|
+
return { type: 'none' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Focus mode: forward most input to child process
|
|
104
|
+
if (focusMode) {
|
|
105
|
+
if (name === 'escape') {
|
|
106
|
+
return { type: 'exit-focus-mode' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const rawInput = getFocusModeInput(event);
|
|
110
|
+
if (rawInput) {
|
|
111
|
+
return { type: 'send-input', data: rawInput };
|
|
112
|
+
}
|
|
113
|
+
return { type: 'none' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Normal mode: handle UI navigation
|
|
117
|
+
|
|
118
|
+
// Quit confirmation handling
|
|
119
|
+
if (quitConfirmPending) {
|
|
120
|
+
if (char === 'q' || name === 'q') {
|
|
121
|
+
return { type: 'quit' };
|
|
122
|
+
}
|
|
123
|
+
return { type: 'cancel-quit' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Quit request
|
|
127
|
+
if (char === 'q' || name === 'q') {
|
|
128
|
+
return { type: 'quit-request' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Ctrl+C for instant quit
|
|
132
|
+
if ((char === 'c' || name === 'c') && ctrl) {
|
|
133
|
+
return { type: 'quit' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Enter focus/input mode
|
|
137
|
+
if (char === 'i' || name === 'i') {
|
|
138
|
+
return { type: 'enter-focus-mode' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Restart current tab
|
|
142
|
+
if (char === 'r' || name === 'r') {
|
|
143
|
+
return { type: 'restart', index: activeIndex };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Kill current tab's process
|
|
147
|
+
if (char === 'k' || name === 'k') {
|
|
148
|
+
return { type: 'kill', index: activeIndex };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Number keys 1-9 for direct tab access
|
|
152
|
+
const input = char || name || '';
|
|
153
|
+
const num = parseInt(input, 10);
|
|
154
|
+
if (num >= 1 && num <= tabCount) {
|
|
155
|
+
return { type: 'switch-tab', index: num - 1 };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Tab navigation
|
|
159
|
+
if (name === 'tab' && shift) {
|
|
160
|
+
return { type: 'prev-tab' };
|
|
161
|
+
}
|
|
162
|
+
if (name === 'tab') {
|
|
163
|
+
return { type: 'next-tab' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Arrow key tab navigation (with meta)
|
|
167
|
+
if ((name === 'left' || name === 'leftArrow') && meta) {
|
|
168
|
+
return { type: 'prev-tab' };
|
|
169
|
+
}
|
|
170
|
+
if ((name === 'right' || name === 'rightArrow') && meta) {
|
|
171
|
+
return { type: 'next-tab' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Scrolling
|
|
175
|
+
if (name === 'up' || name === 'upArrow') {
|
|
176
|
+
return { type: 'scroll', delta: -1 };
|
|
177
|
+
}
|
|
178
|
+
if (name === 'down' || name === 'downArrow') {
|
|
179
|
+
return { type: 'scroll', delta: 1 };
|
|
180
|
+
}
|
|
181
|
+
if (name === 'pageup' || name === 'pageUp') {
|
|
182
|
+
return { type: 'scroll', delta: -viewHeight };
|
|
183
|
+
}
|
|
184
|
+
if (name === 'pagedown' || name === 'pageDown') {
|
|
185
|
+
return { type: 'scroll', delta: viewHeight };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { type: 'none' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get the raw input to send to child process when in focus mode.
|
|
193
|
+
*/
|
|
194
|
+
function getFocusModeInput(event: NormalizedKeyEvent): string | null {
|
|
195
|
+
const { char, name, ctrl } = event;
|
|
196
|
+
|
|
197
|
+
// Map special keys to escape sequences
|
|
198
|
+
if (name === 'return') return KEY_SEQUENCES.RETURN;
|
|
199
|
+
if (name === 'tab') return KEY_SEQUENCES.TAB;
|
|
200
|
+
if (name === 'backspace') return KEY_SEQUENCES.BACKSPACE;
|
|
201
|
+
if (name === 'delete') return KEY_SEQUENCES.DELETE;
|
|
202
|
+
if (name === 'up' || name === 'upArrow') return KEY_SEQUENCES.ARROW_UP;
|
|
203
|
+
if (name === 'down' || name === 'downArrow') return KEY_SEQUENCES.ARROW_DOWN;
|
|
204
|
+
if (name === 'right' || name === 'rightArrow') return KEY_SEQUENCES.ARROW_RIGHT;
|
|
205
|
+
if (name === 'left' || name === 'leftArrow') return KEY_SEQUENCES.ARROW_LEFT;
|
|
206
|
+
|
|
207
|
+
// Ctrl+key combinations
|
|
208
|
+
if (ctrl && name && name.length === 1) {
|
|
209
|
+
return ctrlKeyToSequence(name);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Regular character input
|
|
213
|
+
if (char) {
|
|
214
|
+
return char;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Hook to create stable references for keyboard callbacks.
|
|
222
|
+
*
|
|
223
|
+
* This solves the common React issue where callbacks in keyboard handlers
|
|
224
|
+
* may have stale closures. The refs always point to the current callback.
|
|
225
|
+
*/
|
|
226
|
+
export function useKeyboardCallbackRefs(callbacks: KeyboardCallbacks) {
|
|
227
|
+
const onQuitRef = useRef(callbacks.onQuit);
|
|
228
|
+
const onQuitRequestRef = useRef(callbacks.onQuitRequest);
|
|
229
|
+
const onRestartRef = useRef(callbacks.onRestart);
|
|
230
|
+
const onKillRef = useRef(callbacks.onKill);
|
|
231
|
+
const onEnterFocusModeRef = useRef(callbacks.onEnterFocusMode);
|
|
232
|
+
const onExitFocusModeRef = useRef(callbacks.onExitFocusMode);
|
|
233
|
+
const onSendInputRef = useRef(callbacks.onSendInput);
|
|
234
|
+
const onToggleHelpRef = useRef(callbacks.onToggleHelp);
|
|
235
|
+
const onCloseHelpRef = useRef(callbacks.onCloseHelp);
|
|
236
|
+
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
onQuitRef.current = callbacks.onQuit;
|
|
239
|
+
onQuitRequestRef.current = callbacks.onQuitRequest;
|
|
240
|
+
onRestartRef.current = callbacks.onRestart;
|
|
241
|
+
onKillRef.current = callbacks.onKill;
|
|
242
|
+
onEnterFocusModeRef.current = callbacks.onEnterFocusMode;
|
|
243
|
+
onExitFocusModeRef.current = callbacks.onExitFocusMode;
|
|
244
|
+
onSendInputRef.current = callbacks.onSendInput;
|
|
245
|
+
onToggleHelpRef.current = callbacks.onToggleHelp;
|
|
246
|
+
onCloseHelpRef.current = callbacks.onCloseHelp;
|
|
247
|
+
}, [callbacks]);
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
onQuitRef,
|
|
251
|
+
onQuitRequestRef,
|
|
252
|
+
onRestartRef,
|
|
253
|
+
onKillRef,
|
|
254
|
+
onEnterFocusModeRef,
|
|
255
|
+
onExitFocusModeRef,
|
|
256
|
+
onSendInputRef,
|
|
257
|
+
onToggleHelpRef,
|
|
258
|
+
onCloseHelpRef,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Execute a keyboard action using the provided callback refs.
|
|
264
|
+
*/
|
|
265
|
+
export function executeKeyboardAction(
|
|
266
|
+
action: KeyboardAction,
|
|
267
|
+
refs: ReturnType<typeof useKeyboardCallbackRefs>,
|
|
268
|
+
scrollBy: (delta: number) => void,
|
|
269
|
+
switchTab: (index: number) => void,
|
|
270
|
+
nextTab: () => void,
|
|
271
|
+
prevTab: () => void
|
|
272
|
+
): void {
|
|
273
|
+
switch (action.type) {
|
|
274
|
+
case 'toggle-help':
|
|
275
|
+
refs.onToggleHelpRef.current();
|
|
276
|
+
break;
|
|
277
|
+
case 'close-help':
|
|
278
|
+
refs.onCloseHelpRef.current();
|
|
279
|
+
break;
|
|
280
|
+
case 'exit-focus-mode':
|
|
281
|
+
refs.onExitFocusModeRef.current();
|
|
282
|
+
break;
|
|
283
|
+
case 'send-input':
|
|
284
|
+
refs.onSendInputRef.current(action.data);
|
|
285
|
+
break;
|
|
286
|
+
case 'quit':
|
|
287
|
+
refs.onQuitRef.current();
|
|
288
|
+
break;
|
|
289
|
+
case 'quit-request':
|
|
290
|
+
case 'cancel-quit':
|
|
291
|
+
refs.onQuitRequestRef.current();
|
|
292
|
+
break;
|
|
293
|
+
case 'enter-focus-mode':
|
|
294
|
+
refs.onEnterFocusModeRef.current();
|
|
295
|
+
break;
|
|
296
|
+
case 'restart':
|
|
297
|
+
refs.onRestartRef.current(action.index);
|
|
298
|
+
break;
|
|
299
|
+
case 'kill':
|
|
300
|
+
refs.onKillRef.current(action.index);
|
|
301
|
+
break;
|
|
302
|
+
case 'switch-tab':
|
|
303
|
+
switchTab(action.index);
|
|
304
|
+
break;
|
|
305
|
+
case 'next-tab':
|
|
306
|
+
nextTab();
|
|
307
|
+
break;
|
|
308
|
+
case 'prev-tab':
|
|
309
|
+
prevTab();
|
|
310
|
+
break;
|
|
311
|
+
case 'scroll':
|
|
312
|
+
scrollBy(action.delta);
|
|
313
|
+
break;
|
|
314
|
+
case 'none':
|
|
315
|
+
// Do nothing
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Tab State Management Hook
|
|
3
|
+
*
|
|
4
|
+
* Framework-agnostic state management for tabbed terminal interfaces.
|
|
5
|
+
* Handles tab selection, scroll offsets, auto-scroll behavior, and help hint visibility.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
9
|
+
import type { TabProcess } from '../types.js';
|
|
10
|
+
import { HELP_HINT_DURATION_MS } from '../constants/keyboard.js';
|
|
11
|
+
|
|
12
|
+
export type UseTabsStateOptions = {
|
|
13
|
+
/** List of tab processes */
|
|
14
|
+
tabs: TabProcess[];
|
|
15
|
+
/** Current active tab index */
|
|
16
|
+
activeIndex: number;
|
|
17
|
+
/** Callback when active index changes */
|
|
18
|
+
onActiveIndexChange: (index: number) => void;
|
|
19
|
+
/** Height of the output view in lines */
|
|
20
|
+
viewHeight: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type TabsState = {
|
|
24
|
+
/** Whether to show the help hint */
|
|
25
|
+
showHelpHint: boolean;
|
|
26
|
+
/** Current scroll offset for the active tab */
|
|
27
|
+
activeScrollOffset: number;
|
|
28
|
+
/** Whether the user can scroll up */
|
|
29
|
+
canScrollUp: boolean;
|
|
30
|
+
/** Whether the user can scroll down */
|
|
31
|
+
canScrollDown: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type TabsActions = {
|
|
35
|
+
/** Scroll by a given delta (positive = down, negative = up) */
|
|
36
|
+
scrollBy: (delta: number) => void;
|
|
37
|
+
/** Switch to a specific tab by index */
|
|
38
|
+
switchTab: (index: number) => void;
|
|
39
|
+
/** Switch to the next tab (wraps around) */
|
|
40
|
+
nextTab: () => void;
|
|
41
|
+
/** Switch to the previous tab (wraps around) */
|
|
42
|
+
prevTab: () => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Hook for managing tabbed interface state.
|
|
47
|
+
*
|
|
48
|
+
* Provides:
|
|
49
|
+
* - Per-tab scroll offsets with auto-scroll behavior
|
|
50
|
+
* - Tab navigation helpers
|
|
51
|
+
* - Help hint auto-dismiss timer
|
|
52
|
+
*/
|
|
53
|
+
export function useTabsState({
|
|
54
|
+
tabs,
|
|
55
|
+
activeIndex,
|
|
56
|
+
onActiveIndexChange,
|
|
57
|
+
viewHeight,
|
|
58
|
+
}: UseTabsStateOptions): TabsState & TabsActions {
|
|
59
|
+
// Show help hint for first N seconds
|
|
60
|
+
const [showHelpHint, setShowHelpHint] = useState(true);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const timer = setTimeout(() => setShowHelpHint(false), HELP_HINT_DURATION_MS);
|
|
63
|
+
return () => clearTimeout(timer);
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
// Per-tab scroll offsets
|
|
67
|
+
const [scrollOffsets, setScrollOffsets] = useState<Map<string, number>>(() => new Map());
|
|
68
|
+
// Per-tab auto-scroll state
|
|
69
|
+
const [autoScroll, setAutoScroll] = useState<Map<string, boolean>>(
|
|
70
|
+
() => new Map(tabs.map((t) => [t.id, true]))
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const activeTab = tabs[activeIndex];
|
|
74
|
+
const activeScrollOffset = scrollOffsets.get(activeTab?.id ?? '') ?? 0;
|
|
75
|
+
|
|
76
|
+
// Calculate scroll state
|
|
77
|
+
const totalLines = activeTab?.output.length ?? 0;
|
|
78
|
+
const maxScroll = Math.max(0, totalLines - viewHeight);
|
|
79
|
+
const canScrollUp = activeScrollOffset > 0;
|
|
80
|
+
const canScrollDown = activeScrollOffset < maxScroll;
|
|
81
|
+
|
|
82
|
+
// Auto-scroll when new output arrives (if auto-scroll enabled for this tab)
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!activeTab) return;
|
|
85
|
+
const shouldAutoScroll = autoScroll.get(activeTab.id) ?? true;
|
|
86
|
+
if (shouldAutoScroll) {
|
|
87
|
+
const maxScroll = Math.max(0, activeTab.output.length - viewHeight);
|
|
88
|
+
setScrollOffsets((prev: Map<string, number>) => {
|
|
89
|
+
const next = new Map(prev);
|
|
90
|
+
next.set(activeTab.id, maxScroll);
|
|
91
|
+
return next;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}, [activeTab, viewHeight, autoScroll]);
|
|
95
|
+
|
|
96
|
+
const scrollBy = useCallback(
|
|
97
|
+
(delta: number) => {
|
|
98
|
+
if (!activeTab) return;
|
|
99
|
+
const maxScroll = Math.max(0, activeTab.output.length - viewHeight);
|
|
100
|
+
setScrollOffsets((prev: Map<string, number>) => {
|
|
101
|
+
const next = new Map(prev);
|
|
102
|
+
const current = prev.get(activeTab.id) ?? 0;
|
|
103
|
+
const newOffset = Math.max(0, Math.min(maxScroll, current + delta));
|
|
104
|
+
next.set(activeTab.id, newOffset);
|
|
105
|
+
|
|
106
|
+
// If scrolled away from bottom, disable auto-scroll
|
|
107
|
+
// If scrolled to bottom, re-enable auto-scroll
|
|
108
|
+
const atBottom = newOffset >= maxScroll;
|
|
109
|
+
setAutoScroll((as: Map<string, boolean>) => {
|
|
110
|
+
const asNext = new Map(as);
|
|
111
|
+
asNext.set(activeTab.id, atBottom);
|
|
112
|
+
return asNext;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return next;
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
[activeTab, viewHeight]
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const switchTab = useCallback(
|
|
122
|
+
(newIndex: number) => {
|
|
123
|
+
if (newIndex >= 0 && newIndex < tabs.length) {
|
|
124
|
+
onActiveIndexChange(newIndex);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
[tabs.length, onActiveIndexChange]
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const nextTab = useCallback(() => {
|
|
131
|
+
switchTab((activeIndex + 1) % tabs.length);
|
|
132
|
+
}, [activeIndex, tabs.length, switchTab]);
|
|
133
|
+
|
|
134
|
+
const prevTab = useCallback(() => {
|
|
135
|
+
switchTab((activeIndex - 1 + tabs.length) % tabs.length);
|
|
136
|
+
}, [activeIndex, tabs.length, switchTab]);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
// State
|
|
140
|
+
showHelpHint,
|
|
141
|
+
activeScrollOffset,
|
|
142
|
+
canScrollUp,
|
|
143
|
+
canScrollDown,
|
|
144
|
+
// Actions
|
|
145
|
+
scrollBy,
|
|
146
|
+
switchTab,
|
|
147
|
+
nextTab,
|
|
148
|
+
prevTab,
|
|
149
|
+
};
|
|
150
|
+
}
|