@vybestack/llxprt-ui 0.7.0-nightly.251211.5750c518a

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.
Files changed (123) hide show
  1. package/PLAN-messages.md +681 -0
  2. package/PLAN.md +47 -0
  3. package/README.md +25 -0
  4. package/bun.lock +1024 -0
  5. package/dev-docs/ARCHITECTURE.md +178 -0
  6. package/dev-docs/CODE_ORGANIZATION.md +232 -0
  7. package/dev-docs/STANDARDS.md +235 -0
  8. package/dev-docs/UI_DESIGN.md +425 -0
  9. package/eslint.config.cjs +194 -0
  10. package/images/nui.png +0 -0
  11. package/llxprt.png +0 -0
  12. package/llxprt.svg +128 -0
  13. package/package.json +66 -0
  14. package/scripts/check-limits.ts +177 -0
  15. package/scripts/start.js +71 -0
  16. package/src/app.tsx +599 -0
  17. package/src/bootstrap.tsx +23 -0
  18. package/src/commands/AuthCommand.tsx +80 -0
  19. package/src/commands/ModelCommand.tsx +102 -0
  20. package/src/commands/ProviderCommand.tsx +103 -0
  21. package/src/commands/ThemeCommand.tsx +71 -0
  22. package/src/features/chat/history.ts +178 -0
  23. package/src/features/chat/index.ts +3 -0
  24. package/src/features/chat/persistentHistory.ts +102 -0
  25. package/src/features/chat/responder.ts +217 -0
  26. package/src/features/completion/completions.ts +161 -0
  27. package/src/features/completion/index.ts +3 -0
  28. package/src/features/completion/slash.test.ts +82 -0
  29. package/src/features/completion/slash.ts +248 -0
  30. package/src/features/completion/suggestions.test.ts +51 -0
  31. package/src/features/completion/suggestions.ts +112 -0
  32. package/src/features/config/configSession.test.ts +189 -0
  33. package/src/features/config/configSession.ts +179 -0
  34. package/src/features/config/index.ts +4 -0
  35. package/src/features/config/llxprtAdapter.integration.test.ts +202 -0
  36. package/src/features/config/llxprtAdapter.test.ts +139 -0
  37. package/src/features/config/llxprtAdapter.ts +257 -0
  38. package/src/features/config/llxprtCommands.test.ts +40 -0
  39. package/src/features/config/llxprtCommands.ts +35 -0
  40. package/src/features/config/llxprtConfig.test.ts +261 -0
  41. package/src/features/config/llxprtConfig.ts +418 -0
  42. package/src/features/theme/index.ts +2 -0
  43. package/src/features/theme/theme.test.ts +51 -0
  44. package/src/features/theme/theme.ts +105 -0
  45. package/src/features/theme/themeManager.ts +84 -0
  46. package/src/hooks/useAppCommands.ts +129 -0
  47. package/src/hooks/useApprovalKeyboard.ts +156 -0
  48. package/src/hooks/useChatStore.test.ts +112 -0
  49. package/src/hooks/useChatStore.ts +252 -0
  50. package/src/hooks/useInputManager.ts +99 -0
  51. package/src/hooks/useKeyboardHandlers.ts +130 -0
  52. package/src/hooks/useListNavigation.test.ts +166 -0
  53. package/src/hooks/useListNavigation.ts +62 -0
  54. package/src/hooks/usePersistentHistory.ts +94 -0
  55. package/src/hooks/useScrollManagement.ts +107 -0
  56. package/src/hooks/useSelectionClipboard.ts +48 -0
  57. package/src/hooks/useSessionManager.test.ts +85 -0
  58. package/src/hooks/useSessionManager.ts +101 -0
  59. package/src/hooks/useStreamingLifecycle.ts +71 -0
  60. package/src/hooks/useStreamingResponder.ts +401 -0
  61. package/src/hooks/useSuggestionSetup.ts +23 -0
  62. package/src/hooks/useToolApproval.test.ts +140 -0
  63. package/src/hooks/useToolApproval.ts +264 -0
  64. package/src/hooks/useToolScheduler.ts +432 -0
  65. package/src/index.ts +3 -0
  66. package/src/jsx.d.ts +11 -0
  67. package/src/lib/clipboard.ts +18 -0
  68. package/src/lib/logger.ts +107 -0
  69. package/src/lib/random.ts +5 -0
  70. package/src/main.tsx +13 -0
  71. package/src/test/mockTheme.ts +51 -0
  72. package/src/types/events.ts +87 -0
  73. package/src/types.ts +13 -0
  74. package/src/ui/components/ChatLayout.tsx +694 -0
  75. package/src/ui/components/CommandComponents.tsx +74 -0
  76. package/src/ui/components/DiffViewer.tsx +306 -0
  77. package/src/ui/components/FilterInput.test.ts +69 -0
  78. package/src/ui/components/FilterInput.tsx +62 -0
  79. package/src/ui/components/HeaderBar.tsx +137 -0
  80. package/src/ui/components/RadioSelect.test.ts +140 -0
  81. package/src/ui/components/RadioSelect.tsx +88 -0
  82. package/src/ui/components/SelectableList.test.ts +83 -0
  83. package/src/ui/components/SelectableList.tsx +35 -0
  84. package/src/ui/components/StatusBar.tsx +45 -0
  85. package/src/ui/components/SuggestionPanel.tsx +102 -0
  86. package/src/ui/components/messages/ModelMessage.tsx +14 -0
  87. package/src/ui/components/messages/SystemMessage.tsx +29 -0
  88. package/src/ui/components/messages/ThinkingMessage.tsx +14 -0
  89. package/src/ui/components/messages/UserMessage.tsx +26 -0
  90. package/src/ui/components/messages/index.ts +15 -0
  91. package/src/ui/components/messages/renderMessage.test.ts +49 -0
  92. package/src/ui/components/messages/renderMessage.tsx +43 -0
  93. package/src/ui/components/messages/types.test.ts +24 -0
  94. package/src/ui/components/messages/types.ts +36 -0
  95. package/src/ui/modals/AuthModal.tsx +106 -0
  96. package/src/ui/modals/ModalShell.tsx +60 -0
  97. package/src/ui/modals/SearchSelectModal.tsx +236 -0
  98. package/src/ui/modals/ThemeModal.tsx +204 -0
  99. package/src/ui/modals/ToolApprovalModal.test.ts +206 -0
  100. package/src/ui/modals/ToolApprovalModal.tsx +282 -0
  101. package/src/ui/modals/index.ts +20 -0
  102. package/src/ui/modals/modals.test.ts +26 -0
  103. package/src/ui/modals/types.ts +19 -0
  104. package/src/uicontext/Command.tsx +102 -0
  105. package/src/uicontext/Dialog.tsx +65 -0
  106. package/src/uicontext/index.ts +2 -0
  107. package/themes/ansi-light.json +59 -0
  108. package/themes/ansi.json +59 -0
  109. package/themes/atom-one-dark.json +59 -0
  110. package/themes/ayu-light.json +59 -0
  111. package/themes/ayu.json +59 -0
  112. package/themes/default-light.json +59 -0
  113. package/themes/default.json +59 -0
  114. package/themes/dracula.json +59 -0
  115. package/themes/github-dark.json +59 -0
  116. package/themes/github-light.json +59 -0
  117. package/themes/googlecode.json +59 -0
  118. package/themes/green-screen.json +59 -0
  119. package/themes/no-color.json +59 -0
  120. package/themes/shades-of-purple.json +59 -0
  121. package/themes/xcode.json +59 -0
  122. package/tsconfig.json +28 -0
  123. package/vitest.config.ts +10 -0
@@ -0,0 +1,99 @@
1
+ import type { RefObject, Dispatch, SetStateAction } from 'react';
2
+ import { useCallback, useState } from 'react';
3
+ import type { TextareaRenderable } from '@vybestack/opentui-core';
4
+ import type { Role } from './useChatStore';
5
+
6
+ type StateSetter<T> = Dispatch<SetStateAction<T>>;
7
+
8
+ const MIN_INPUT_LINES = 1;
9
+ const MAX_INPUT_LINES = 10;
10
+
11
+ function clampInputLines(value: number): number {
12
+ return Math.min(MAX_INPUT_LINES, Math.max(MIN_INPUT_LINES, value));
13
+ }
14
+
15
+ export interface UseInputManagerReturn {
16
+ inputLineCount: number;
17
+ enforceInputLineBounds: () => void;
18
+ handleSubmit: () => Promise<void>;
19
+ handleTabComplete: () => void;
20
+ }
21
+
22
+ export function useInputManager(
23
+ textareaRef: RefObject<TextareaRenderable | null>,
24
+ appendMessage: (role: Role, text: string) => string,
25
+ setPromptCount: StateSetter<number>,
26
+ setAutoFollow: StateSetter<boolean>,
27
+ startStreamingResponder: (prompt: string) => Promise<void>,
28
+ refreshCompletion: () => void,
29
+ clearCompletion: () => void,
30
+ applyCompletion: () => void,
31
+ handleCommand: (command: string) => Promise<boolean>,
32
+ recordHistory: (prompt: string) => void,
33
+ ): UseInputManagerReturn {
34
+ const [inputLineCount, setInputLineCount] = useState(MIN_INPUT_LINES);
35
+
36
+ const enforceInputLineBounds = useCallback(() => {
37
+ const editor = textareaRef.current;
38
+ if (editor == null) {
39
+ return;
40
+ }
41
+ const clamped = clampInputLines(editor.lineCount);
42
+ setInputLineCount(clamped);
43
+ refreshCompletion();
44
+ }, [refreshCompletion, textareaRef]);
45
+
46
+ const handleSubmit = useCallback(async () => {
47
+ const editor = textareaRef.current;
48
+ if (editor == null) {
49
+ return;
50
+ }
51
+ const raw = editor.plainText.trimEnd();
52
+ if (raw.trim().length === 0) {
53
+ return;
54
+ }
55
+ const trimmed = raw.trim();
56
+ if (await handleCommand(trimmed)) {
57
+ recordHistory(raw);
58
+ editor.clear();
59
+ setInputLineCount(MIN_INPUT_LINES);
60
+ setAutoFollow(true);
61
+ clearCompletion();
62
+ editor.submit();
63
+ return;
64
+ }
65
+ if (trimmed === '/quit') {
66
+ process.exit(0);
67
+ }
68
+ recordHistory(raw);
69
+ appendMessage('user', raw);
70
+ setPromptCount((count) => count + 1);
71
+ editor.clear();
72
+ setInputLineCount(MIN_INPUT_LINES);
73
+ setAutoFollow(true);
74
+ clearCompletion();
75
+ editor.submit();
76
+ await startStreamingResponder(trimmed);
77
+ }, [
78
+ appendMessage,
79
+ clearCompletion,
80
+ handleCommand,
81
+ recordHistory,
82
+ setAutoFollow,
83
+ setPromptCount,
84
+ startStreamingResponder,
85
+ textareaRef,
86
+ ]);
87
+
88
+ const handleTabComplete = useCallback(() => {
89
+ applyCompletion();
90
+ refreshCompletion();
91
+ }, [applyCompletion, refreshCompletion]);
92
+
93
+ return {
94
+ inputLineCount,
95
+ enforceInputLineBounds,
96
+ handleSubmit,
97
+ handleTabComplete,
98
+ };
99
+ }
@@ -0,0 +1,130 @@
1
+ import type { KeyEvent, TextareaRenderable } from '@vybestack/opentui-core';
2
+ import type { RefObject } from 'react';
3
+ import { useKeyboard } from '@vybestack/opentui-react';
4
+ import { useCallback, useEffect, useRef } from 'react';
5
+ import { getLogger } from '../lib/logger';
6
+
7
+ const logger = getLogger('nui:keyboard');
8
+
9
+ function isEnterKey(key: KeyEvent): boolean {
10
+ // Don't match linefeed (\n / shift+enter) - let textarea handle it as newline
11
+ return (
12
+ key.name === 'return' ||
13
+ key.name === 'enter' ||
14
+ key.name === 'kpenter' ||
15
+ key.name === 'kpplus' ||
16
+ key.sequence === '\r'
17
+ );
18
+ }
19
+
20
+ export function useEnterSubmit(onSubmit: () => void, isBlocked: boolean): void {
21
+ useKeyboard((key) => {
22
+ if (!isEnterKey(key) || isBlocked) return;
23
+ const hasModifier =
24
+ key.shift === true ||
25
+ key.ctrl === true ||
26
+ key.meta === true ||
27
+ key.option === true ||
28
+ key.super === true;
29
+ if (!hasModifier) {
30
+ key.preventDefault();
31
+ onSubmit();
32
+ }
33
+ });
34
+ }
35
+
36
+ export function useFocusAndMount(
37
+ textareaRef: RefObject<TextareaRenderable | null>,
38
+ mountedRef: RefObject<boolean>,
39
+ ): void {
40
+ useEffect(() => {
41
+ textareaRef.current?.focus();
42
+ return () => {
43
+ mountedRef.current = false;
44
+ };
45
+ }, [mountedRef, textareaRef]);
46
+ }
47
+
48
+ export function useSuggestionKeybindings(
49
+ suggestionCount: number,
50
+ moveSelection: (delta: number) => void,
51
+ handleTabComplete: () => void,
52
+ cancelAll: () => void,
53
+ clearInput: () => Promise<void>,
54
+ isBusy: () => boolean,
55
+ isInputEmpty: () => boolean,
56
+ ): void {
57
+ const hasSuggestions = suggestionCount > 0;
58
+
59
+ // Use refs to avoid stale closures
60
+ const cancelAllRef = useRef(cancelAll);
61
+ const clearInputRef = useRef(clearInput);
62
+ const isBusyRef = useRef(isBusy);
63
+ const isInputEmptyRef = useRef(isInputEmpty);
64
+
65
+ useEffect(() => {
66
+ cancelAllRef.current = cancelAll;
67
+ }, [cancelAll]);
68
+ useEffect(() => {
69
+ clearInputRef.current = clearInput;
70
+ }, [clearInput]);
71
+ useEffect(() => {
72
+ isBusyRef.current = isBusy;
73
+ }, [isBusy]);
74
+ useEffect(() => {
75
+ isInputEmptyRef.current = isInputEmpty;
76
+ }, [isInputEmpty]);
77
+
78
+ useKeyboard((key) => {
79
+ if (hasSuggestions && key.name === 'down') {
80
+ key.preventDefault();
81
+ moveSelection(1);
82
+ } else if (hasSuggestions && key.name === 'up') {
83
+ key.preventDefault();
84
+ moveSelection(-1);
85
+ } else if (hasSuggestions && key.name === 'tab') {
86
+ key.preventDefault();
87
+ handleTabComplete();
88
+ } else if (key.name === 'escape') {
89
+ const empty = isInputEmptyRef.current();
90
+ const busy = isBusyRef.current();
91
+ logger.debug('Escape pressed', 'inputEmpty:', empty, 'busy:', busy);
92
+
93
+ // First Esc clears input if it has text
94
+ // Second Esc (or first if input empty) cancels streaming/tools
95
+ if (!empty) {
96
+ logger.debug('Clearing input');
97
+ void clearInputRef.current();
98
+ } else if (busy) {
99
+ logger.debug('Cancelling all');
100
+ cancelAllRef.current();
101
+ }
102
+ }
103
+ });
104
+ }
105
+
106
+ export function useLineIdGenerator(): () => string {
107
+ const nextLineId = useRef(0);
108
+ return useCallback((): string => {
109
+ nextLineId.current += 1;
110
+ return `line-${nextLineId.current}`;
111
+ }, []);
112
+ }
113
+
114
+ export function useHistoryNavigation(
115
+ modalOpen: boolean,
116
+ suggestionCount: number,
117
+ handleHistoryKey: (direction: 'up' | 'down') => boolean,
118
+ ): void {
119
+ useKeyboard((key) => {
120
+ if (modalOpen || suggestionCount > 0 || key.eventType !== 'press') {
121
+ return;
122
+ }
123
+ if (key.name === 'up' || key.name === 'down') {
124
+ const handled = handleHistoryKey(key.name);
125
+ if (handled) {
126
+ key.preventDefault();
127
+ }
128
+ }
129
+ });
130
+ }
@@ -0,0 +1,166 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { renderHook, act } from '@testing-library/react';
3
+ import { useListNavigation, useFilteredList } from './useListNavigation';
4
+
5
+ describe('useListNavigation', () => {
6
+ it('initializes with selectedIndex 0', () => {
7
+ const { result } = renderHook(() => useListNavigation(5));
8
+ expect(result.current.selectedIndex).toBe(0);
9
+ });
10
+
11
+ it('moves selection down within bounds', () => {
12
+ const { result } = renderHook(() => useListNavigation(5));
13
+ act(() => {
14
+ result.current.moveSelection(1);
15
+ });
16
+ expect(result.current.selectedIndex).toBe(1);
17
+ });
18
+
19
+ it('moves selection up within bounds', () => {
20
+ const { result } = renderHook(() => useListNavigation(5));
21
+ act(() => {
22
+ result.current.setSelectedIndex(3);
23
+ });
24
+ act(() => {
25
+ result.current.moveSelection(-1);
26
+ });
27
+ expect(result.current.selectedIndex).toBe(2);
28
+ });
29
+
30
+ it('clamps selection to 0 when moving below minimum', () => {
31
+ const { result } = renderHook(() => useListNavigation(5));
32
+ act(() => {
33
+ result.current.moveSelection(-5);
34
+ });
35
+ expect(result.current.selectedIndex).toBe(0);
36
+ });
37
+
38
+ it('clamps selection to length-1 when moving above maximum', () => {
39
+ const { result } = renderHook(() => useListNavigation(5));
40
+ act(() => {
41
+ result.current.moveSelection(10);
42
+ });
43
+ expect(result.current.selectedIndex).toBe(4);
44
+ });
45
+
46
+ it('handles empty list by clamping to 0', () => {
47
+ const { result } = renderHook(() => useListNavigation(0));
48
+ act(() => {
49
+ result.current.moveSelection(5);
50
+ });
51
+ expect(result.current.selectedIndex).toBe(0);
52
+ });
53
+
54
+ it('allows direct setting of selectedIndex', () => {
55
+ const { result } = renderHook(() => useListNavigation(5));
56
+ act(() => {
57
+ result.current.setSelectedIndex(3);
58
+ });
59
+ expect(result.current.selectedIndex).toBe(3);
60
+ });
61
+
62
+ it('updates when length changes', () => {
63
+ const { result, rerender } = renderHook(
64
+ ({ length }) => useListNavigation(length),
65
+ {
66
+ initialProps: { length: 5 },
67
+ },
68
+ );
69
+ act(() => {
70
+ result.current.setSelectedIndex(4);
71
+ });
72
+ expect(result.current.selectedIndex).toBe(4);
73
+
74
+ rerender({ length: 3 });
75
+ act(() => {
76
+ result.current.moveSelection(0);
77
+ });
78
+ expect(result.current.selectedIndex).toBe(2);
79
+ });
80
+ });
81
+
82
+ describe('useFilteredList', () => {
83
+ interface TestItem {
84
+ readonly id: string;
85
+ readonly name: string;
86
+ }
87
+
88
+ const items: TestItem[] = [
89
+ { id: '1', name: 'Apple' },
90
+ { id: '2', name: 'Banana' },
91
+ { id: '3', name: 'Cherry' },
92
+ { id: '4', name: 'Apricot' },
93
+ ];
94
+
95
+ const filterFn = (item: TestItem, query: string): boolean => {
96
+ return item.name.toLowerCase().includes(query.toLowerCase());
97
+ };
98
+
99
+ it('returns all items when query is empty', () => {
100
+ const { result } = renderHook(() => useFilteredList(items, '', filterFn));
101
+ expect(result.current.filteredItems).toHaveLength(4);
102
+ expect(result.current.filteredItems).toStrictEqual(items);
103
+ });
104
+
105
+ it('filters items based on query', () => {
106
+ const { result } = renderHook(() => useFilteredList(items, 'ap', filterFn));
107
+ expect(result.current.filteredItems).toHaveLength(2);
108
+ expect(result.current.filteredItems.map((item) => item.id)).toStrictEqual([
109
+ '1',
110
+ '4',
111
+ ]);
112
+ });
113
+
114
+ it('resets selectedIndex to 0 when query changes', () => {
115
+ const { result, rerender } = renderHook(
116
+ ({ query }) => useFilteredList(items, query, filterFn),
117
+ { initialProps: { query: '' } },
118
+ );
119
+
120
+ act(() => {
121
+ result.current.setSelectedIndex(2);
122
+ });
123
+ expect(result.current.selectedIndex).toBe(2);
124
+
125
+ rerender({ query: 'ban' });
126
+ expect(result.current.selectedIndex).toBe(0);
127
+ });
128
+
129
+ it('exposes moveSelection from useListNavigation', () => {
130
+ const { result } = renderHook(() => useFilteredList(items, 'ap', filterFn));
131
+ expect(result.current.selectedIndex).toBe(0);
132
+
133
+ act(() => {
134
+ result.current.moveSelection(1);
135
+ });
136
+ expect(result.current.selectedIndex).toBe(1);
137
+ });
138
+
139
+ it('clamps selection when filtered list shrinks', () => {
140
+ const { result, rerender } = renderHook(
141
+ ({ query }) => useFilteredList(items, query, filterFn),
142
+ { initialProps: { query: '' } },
143
+ );
144
+
145
+ act(() => {
146
+ result.current.setSelectedIndex(3);
147
+ });
148
+ expect(result.current.selectedIndex).toBe(3);
149
+
150
+ rerender({ query: 'ban' });
151
+ expect(result.current.selectedIndex).toBe(0);
152
+ });
153
+
154
+ it('memoizes filtered items', () => {
155
+ const { result, rerender } = renderHook(
156
+ ({ query }) => useFilteredList(items, query, filterFn),
157
+ { initialProps: { query: 'ap' } },
158
+ );
159
+
160
+ const firstResult = result.current.filteredItems;
161
+ rerender({ query: 'ap' });
162
+ const secondResult = result.current.filteredItems;
163
+
164
+ expect(firstResult).toBe(secondResult);
165
+ });
166
+ });
@@ -0,0 +1,62 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+
3
+ export interface ListNavigationResult {
4
+ readonly selectedIndex: number;
5
+ readonly setSelectedIndex: (value: number) => void;
6
+ readonly moveSelection: (delta: number) => void;
7
+ }
8
+
9
+ export function useListNavigation(length: number): ListNavigationResult {
10
+ const [selectedIndex, setSelectedIndex] = useState(0);
11
+
12
+ const moveSelection = (delta: number): void => {
13
+ setSelectedIndex((current) => {
14
+ if (length === 0) {
15
+ return 0;
16
+ }
17
+ const next = current + delta;
18
+ return Math.max(0, Math.min(next, length - 1));
19
+ });
20
+ };
21
+
22
+ return {
23
+ selectedIndex,
24
+ setSelectedIndex,
25
+ moveSelection,
26
+ };
27
+ }
28
+
29
+ export interface FilteredListResult<T> {
30
+ readonly filteredItems: T[];
31
+ readonly selectedIndex: number;
32
+ readonly setSelectedIndex: (value: number) => void;
33
+ readonly moveSelection: (delta: number) => void;
34
+ }
35
+
36
+ export function useFilteredList<T>(
37
+ items: T[],
38
+ query: string,
39
+ filterFn: (item: T, query: string) => boolean,
40
+ ): FilteredListResult<T> {
41
+ const filteredItems = useMemo(() => {
42
+ if (!query.trim()) {
43
+ return items;
44
+ }
45
+ return items.filter((item) => filterFn(item, query));
46
+ }, [items, query, filterFn]);
47
+
48
+ const { selectedIndex, setSelectedIndex, moveSelection } = useListNavigation(
49
+ filteredItems.length,
50
+ );
51
+
52
+ useEffect(() => {
53
+ setSelectedIndex(0);
54
+ }, [query, setSelectedIndex]);
55
+
56
+ return {
57
+ filteredItems,
58
+ selectedIndex,
59
+ setSelectedIndex,
60
+ moveSelection,
61
+ };
62
+ }
@@ -0,0 +1,94 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type { PersistentHistoryService } from '../features/chat/persistentHistory';
3
+ import { createPersistentHistory } from '../features/chat/persistentHistory';
4
+ import { getLogger } from '../lib/logger';
5
+
6
+ const logger = getLogger('nui:use-persistent-history');
7
+
8
+ export interface UsePersistentHistoryOptions {
9
+ /** Working directory for the session */
10
+ workingDir: string | null;
11
+ /** Session ID */
12
+ sessionId: string | null;
13
+ }
14
+
15
+ export interface UsePersistentHistoryReturn {
16
+ /** The persistent history service, null if not initialized */
17
+ service: PersistentHistoryService | null;
18
+ /** Whether the service is ready */
19
+ isReady: boolean;
20
+ }
21
+
22
+ /**
23
+ * Hook to manage persistent history service lifecycle.
24
+ * Initializes when workingDir and sessionId are provided,
25
+ * cleans up when they change or component unmounts.
26
+ */
27
+ export function usePersistentHistory(
28
+ options: UsePersistentHistoryOptions,
29
+ ): UsePersistentHistoryReturn {
30
+ const { workingDir, sessionId } = options;
31
+ const [service, setService] = useState<PersistentHistoryService | null>(null);
32
+ const [isReady, setIsReady] = useState(false);
33
+ const serviceRef = useRef<PersistentHistoryService | null>(null);
34
+
35
+ useEffect(() => {
36
+ // Clean up previous service
37
+ if (serviceRef.current) {
38
+ logger.debug('Closing previous persistent history service');
39
+ serviceRef.current.close();
40
+ serviceRef.current = null;
41
+ setService(null);
42
+ setIsReady(false);
43
+ }
44
+
45
+ // Don't initialize without required params
46
+ if (!workingDir || !sessionId) {
47
+ return;
48
+ }
49
+
50
+ let cancelled = false;
51
+
52
+ const initService = async () => {
53
+ logger.debug('Initializing persistent history', {
54
+ workingDir,
55
+ sessionId,
56
+ });
57
+ const newService = createPersistentHistory(workingDir, sessionId);
58
+
59
+ try {
60
+ await newService.initialize();
61
+
62
+ if (cancelled) {
63
+ newService.close();
64
+ return;
65
+ }
66
+
67
+ serviceRef.current = newService;
68
+ setService(newService);
69
+ setIsReady(true);
70
+ logger.debug('Persistent history ready', {
71
+ historyCount: newService.count,
72
+ });
73
+ } catch (err) {
74
+ logger.error('Failed to initialize persistent history:', String(err));
75
+ if (!cancelled) {
76
+ setService(null);
77
+ setIsReady(false);
78
+ }
79
+ }
80
+ };
81
+
82
+ void initService();
83
+
84
+ return () => {
85
+ cancelled = true;
86
+ if (serviceRef.current) {
87
+ serviceRef.current.close();
88
+ serviceRef.current = null;
89
+ }
90
+ };
91
+ }, [workingDir, sessionId]);
92
+
93
+ return { service, isReady };
94
+ }
@@ -0,0 +1,107 @@
1
+ import type { RefObject, Dispatch, SetStateAction } from 'react';
2
+ import { useCallback, useState } from 'react';
3
+ import { useKeyboard } from '@vybestack/opentui-react';
4
+ import type { ScrollBoxRenderable } from '@vybestack/opentui-core';
5
+
6
+ type StateSetter<T> = Dispatch<SetStateAction<T>>;
7
+
8
+ const SCROLL_STEP = 2;
9
+ const PAGE_STEP = 10;
10
+
11
+ export interface UseScrollManagementReturn {
12
+ autoFollow: boolean;
13
+ setAutoFollow: StateSetter<boolean>;
14
+ scrollBy: (delta: number) => void;
15
+ jumpToBottom: () => void;
16
+ handleContentChange: () => void;
17
+ handleMouseScroll: (event: { type: string }) => void;
18
+ }
19
+
20
+ export function useScrollManagement(
21
+ scrollRef: RefObject<ScrollBoxRenderable | null>,
22
+ ): UseScrollManagementReturn {
23
+ const [autoFollow, setAutoFollow] = useState(true);
24
+
25
+ const scrollToBottom = useCallback(() => {
26
+ const scrollBox = scrollRef.current;
27
+ if (scrollBox == null) {
28
+ return;
29
+ }
30
+ scrollBox.scrollTo({ x: 0, y: scrollBox.scrollHeight });
31
+ }, [scrollRef]);
32
+
33
+ const isAtBottom = useCallback((box: ScrollBoxRenderable): boolean => {
34
+ const viewportHeight = box.viewport.height;
35
+ return box.scrollTop + viewportHeight >= box.scrollHeight - 1;
36
+ }, []);
37
+
38
+ const scrollBy = useCallback(
39
+ (delta: number) => {
40
+ const scrollBox = scrollRef.current;
41
+ if (scrollBox == null) {
42
+ return;
43
+ }
44
+ scrollBox.scrollTo({ x: 0, y: scrollBox.scrollTop + delta });
45
+ if (delta < 0) {
46
+ setAutoFollow(false);
47
+ return;
48
+ }
49
+ setAutoFollow(isAtBottom(scrollBox));
50
+ },
51
+ [isAtBottom, scrollRef],
52
+ );
53
+
54
+ const jumpToBottom = useCallback(() => {
55
+ scrollToBottom();
56
+ setAutoFollow(true);
57
+ }, [scrollToBottom]);
58
+
59
+ const handleContentChange = useCallback(() => {
60
+ if (autoFollow) {
61
+ scrollToBottom();
62
+ }
63
+ }, [autoFollow, scrollToBottom]);
64
+
65
+ const handleMouseScroll = useCallback(
66
+ (event: { type: string }) => {
67
+ if (event.type !== 'scroll') {
68
+ return;
69
+ }
70
+ const scrollBox = scrollRef.current;
71
+ if (scrollBox == null) {
72
+ return;
73
+ }
74
+ setAutoFollow(isAtBottom(scrollBox));
75
+ },
76
+ [isAtBottom, scrollRef],
77
+ );
78
+
79
+ useKeyboard((key) => {
80
+ if (key.name === 'pageup' || (key.ctrl && key.name === 'up')) {
81
+ scrollBy(-PAGE_STEP);
82
+ } else if (key.name === 'pagedown' || (key.ctrl && key.name === 'down')) {
83
+ scrollBy(PAGE_STEP);
84
+ } else if (key.name === 'end') {
85
+ jumpToBottom();
86
+ } else if (key.name === 'home') {
87
+ setAutoFollow(false);
88
+ const scrollBox = scrollRef.current;
89
+ if (scrollBox != null) {
90
+ scrollBox.scrollTo({ x: 0, y: 0 });
91
+ }
92
+ } else if (key.ctrl && key.name === 'b') {
93
+ scrollBy(-SCROLL_STEP);
94
+ } else if (key.ctrl && key.name === 'f') {
95
+ scrollBy(SCROLL_STEP);
96
+ }
97
+ });
98
+
99
+ return {
100
+ autoFollow,
101
+ setAutoFollow,
102
+ scrollBy,
103
+ jumpToBottom,
104
+ handleContentChange,
105
+ handleMouseScroll,
106
+ };
107
+ }
@@ -0,0 +1,48 @@
1
+ import { useCallback } from 'react';
2
+ import clipboard from 'clipboardy';
3
+
4
+ /**
5
+ * Hook that returns a handler for copying selected text to clipboard.
6
+ * Uses OSC 52 escape sequence for terminal clipboard support (including tmux).
7
+ */
8
+ export function useSelectionClipboard(renderer: unknown): () => void {
9
+ return useCallback(() => {
10
+ const rendererWithSelection = renderer as {
11
+ getSelection?: () => { getSelectedText?: () => string | null } | null;
12
+ };
13
+ if (rendererWithSelection.getSelection == null) {
14
+ return;
15
+ }
16
+ const selection = rendererWithSelection.getSelection();
17
+ if (selection?.getSelectedText == null) {
18
+ return;
19
+ }
20
+ const text = selection.getSelectedText() ?? '';
21
+ if (text.length === 0) {
22
+ return;
23
+ }
24
+ // Send OSC 52 to terminal for clipboard
25
+ const osc = buildOsc52(text);
26
+ try {
27
+ const rendererWithWrite = renderer as {
28
+ writeOut?: (chunk: string) => void;
29
+ };
30
+ if (rendererWithWrite.writeOut != null) {
31
+ rendererWithWrite.writeOut(osc);
32
+ }
33
+ } catch {
34
+ // ignore renderer write failures
35
+ }
36
+ // Also copy via clipboardy as fallback
37
+ void clipboard.write(text).catch(() => undefined);
38
+ }, [renderer]);
39
+ }
40
+
41
+ function buildOsc52(text: string): string {
42
+ const base64 = Buffer.from(text).toString('base64');
43
+ const osc52 = `\u001b]52;c;${base64}\u0007`;
44
+ if (process.env.TMUX) {
45
+ return `\u001bPtmux;\u001b${osc52}\u001b\\`;
46
+ }
47
+ return osc52;
48
+ }