@pokit/tabs-core 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pokit/tabs-core",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
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": "./src/index.ts",
29
- "module": "./src/index.ts",
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.1"
48
+ "@pokit/core": "0.0.3"
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.1"
59
+ "@pokit/core": "0.0.3"
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
+ }