@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.
- package/PLAN-messages.md +681 -0
- package/PLAN.md +47 -0
- package/README.md +25 -0
- package/bun.lock +1024 -0
- package/dev-docs/ARCHITECTURE.md +178 -0
- package/dev-docs/CODE_ORGANIZATION.md +232 -0
- package/dev-docs/STANDARDS.md +235 -0
- package/dev-docs/UI_DESIGN.md +425 -0
- package/eslint.config.cjs +194 -0
- package/images/nui.png +0 -0
- package/llxprt.png +0 -0
- package/llxprt.svg +128 -0
- package/package.json +66 -0
- package/scripts/check-limits.ts +177 -0
- package/scripts/start.js +71 -0
- package/src/app.tsx +599 -0
- package/src/bootstrap.tsx +23 -0
- package/src/commands/AuthCommand.tsx +80 -0
- package/src/commands/ModelCommand.tsx +102 -0
- package/src/commands/ProviderCommand.tsx +103 -0
- package/src/commands/ThemeCommand.tsx +71 -0
- package/src/features/chat/history.ts +178 -0
- package/src/features/chat/index.ts +3 -0
- package/src/features/chat/persistentHistory.ts +102 -0
- package/src/features/chat/responder.ts +217 -0
- package/src/features/completion/completions.ts +161 -0
- package/src/features/completion/index.ts +3 -0
- package/src/features/completion/slash.test.ts +82 -0
- package/src/features/completion/slash.ts +248 -0
- package/src/features/completion/suggestions.test.ts +51 -0
- package/src/features/completion/suggestions.ts +112 -0
- package/src/features/config/configSession.test.ts +189 -0
- package/src/features/config/configSession.ts +179 -0
- package/src/features/config/index.ts +4 -0
- package/src/features/config/llxprtAdapter.integration.test.ts +202 -0
- package/src/features/config/llxprtAdapter.test.ts +139 -0
- package/src/features/config/llxprtAdapter.ts +257 -0
- package/src/features/config/llxprtCommands.test.ts +40 -0
- package/src/features/config/llxprtCommands.ts +35 -0
- package/src/features/config/llxprtConfig.test.ts +261 -0
- package/src/features/config/llxprtConfig.ts +418 -0
- package/src/features/theme/index.ts +2 -0
- package/src/features/theme/theme.test.ts +51 -0
- package/src/features/theme/theme.ts +105 -0
- package/src/features/theme/themeManager.ts +84 -0
- package/src/hooks/useAppCommands.ts +129 -0
- package/src/hooks/useApprovalKeyboard.ts +156 -0
- package/src/hooks/useChatStore.test.ts +112 -0
- package/src/hooks/useChatStore.ts +252 -0
- package/src/hooks/useInputManager.ts +99 -0
- package/src/hooks/useKeyboardHandlers.ts +130 -0
- package/src/hooks/useListNavigation.test.ts +166 -0
- package/src/hooks/useListNavigation.ts +62 -0
- package/src/hooks/usePersistentHistory.ts +94 -0
- package/src/hooks/useScrollManagement.ts +107 -0
- package/src/hooks/useSelectionClipboard.ts +48 -0
- package/src/hooks/useSessionManager.test.ts +85 -0
- package/src/hooks/useSessionManager.ts +101 -0
- package/src/hooks/useStreamingLifecycle.ts +71 -0
- package/src/hooks/useStreamingResponder.ts +401 -0
- package/src/hooks/useSuggestionSetup.ts +23 -0
- package/src/hooks/useToolApproval.test.ts +140 -0
- package/src/hooks/useToolApproval.ts +264 -0
- package/src/hooks/useToolScheduler.ts +432 -0
- package/src/index.ts +3 -0
- package/src/jsx.d.ts +11 -0
- package/src/lib/clipboard.ts +18 -0
- package/src/lib/logger.ts +107 -0
- package/src/lib/random.ts +5 -0
- package/src/main.tsx +13 -0
- package/src/test/mockTheme.ts +51 -0
- package/src/types/events.ts +87 -0
- package/src/types.ts +13 -0
- package/src/ui/components/ChatLayout.tsx +694 -0
- package/src/ui/components/CommandComponents.tsx +74 -0
- package/src/ui/components/DiffViewer.tsx +306 -0
- package/src/ui/components/FilterInput.test.ts +69 -0
- package/src/ui/components/FilterInput.tsx +62 -0
- package/src/ui/components/HeaderBar.tsx +137 -0
- package/src/ui/components/RadioSelect.test.ts +140 -0
- package/src/ui/components/RadioSelect.tsx +88 -0
- package/src/ui/components/SelectableList.test.ts +83 -0
- package/src/ui/components/SelectableList.tsx +35 -0
- package/src/ui/components/StatusBar.tsx +45 -0
- package/src/ui/components/SuggestionPanel.tsx +102 -0
- package/src/ui/components/messages/ModelMessage.tsx +14 -0
- package/src/ui/components/messages/SystemMessage.tsx +29 -0
- package/src/ui/components/messages/ThinkingMessage.tsx +14 -0
- package/src/ui/components/messages/UserMessage.tsx +26 -0
- package/src/ui/components/messages/index.ts +15 -0
- package/src/ui/components/messages/renderMessage.test.ts +49 -0
- package/src/ui/components/messages/renderMessage.tsx +43 -0
- package/src/ui/components/messages/types.test.ts +24 -0
- package/src/ui/components/messages/types.ts +36 -0
- package/src/ui/modals/AuthModal.tsx +106 -0
- package/src/ui/modals/ModalShell.tsx +60 -0
- package/src/ui/modals/SearchSelectModal.tsx +236 -0
- package/src/ui/modals/ThemeModal.tsx +204 -0
- package/src/ui/modals/ToolApprovalModal.test.ts +206 -0
- package/src/ui/modals/ToolApprovalModal.tsx +282 -0
- package/src/ui/modals/index.ts +20 -0
- package/src/ui/modals/modals.test.ts +26 -0
- package/src/ui/modals/types.ts +19 -0
- package/src/uicontext/Command.tsx +102 -0
- package/src/uicontext/Dialog.tsx +65 -0
- package/src/uicontext/index.ts +2 -0
- package/themes/ansi-light.json +59 -0
- package/themes/ansi.json +59 -0
- package/themes/atom-one-dark.json +59 -0
- package/themes/ayu-light.json +59 -0
- package/themes/ayu.json +59 -0
- package/themes/default-light.json +59 -0
- package/themes/default.json +59 -0
- package/themes/dracula.json +59 -0
- package/themes/github-dark.json +59 -0
- package/themes/github-light.json +59 -0
- package/themes/googlecode.json +59 -0
- package/themes/green-screen.json +59 -0
- package/themes/no-color.json +59 -0
- package/themes/shades-of-purple.json +59 -0
- package/themes/xcode.json +59 -0
- package/tsconfig.json +28 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { useCommand } from '../uicontext';
|
|
9
|
+
import { SearchSelectModal } from '../ui/modals';
|
|
10
|
+
import type { SearchItem } from '../ui/modals/types';
|
|
11
|
+
import type { SessionConfig } from '../features/config';
|
|
12
|
+
import type { ThemeDefinition } from '../features/theme';
|
|
13
|
+
|
|
14
|
+
interface ModelCommandProps {
|
|
15
|
+
readonly fetchModelItems: () => Promise<{
|
|
16
|
+
items: SearchItem[];
|
|
17
|
+
messages?: string[];
|
|
18
|
+
}>;
|
|
19
|
+
readonly sessionConfig: SessionConfig;
|
|
20
|
+
readonly setSessionConfig: (config: SessionConfig) => void;
|
|
21
|
+
readonly appendMessage: (
|
|
22
|
+
role: 'user' | 'model' | 'system',
|
|
23
|
+
text: string,
|
|
24
|
+
) => string;
|
|
25
|
+
readonly theme: ThemeDefinition;
|
|
26
|
+
readonly focusInput: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ModelCommand({
|
|
30
|
+
fetchModelItems,
|
|
31
|
+
sessionConfig,
|
|
32
|
+
setSessionConfig,
|
|
33
|
+
appendMessage,
|
|
34
|
+
theme,
|
|
35
|
+
focusInput,
|
|
36
|
+
}: ModelCommandProps): React.ReactNode | null {
|
|
37
|
+
const { register } = useCommand();
|
|
38
|
+
const dialogClearRef = useRef<(() => void) | null>(null);
|
|
39
|
+
const [modalItems, setModalItems] = useState<SearchItem[]>([]);
|
|
40
|
+
|
|
41
|
+
const handleClose = useCallback((): void => {
|
|
42
|
+
if (dialogClearRef.current !== null) {
|
|
43
|
+
dialogClearRef.current();
|
|
44
|
+
}
|
|
45
|
+
focusInput();
|
|
46
|
+
}, [focusInput]);
|
|
47
|
+
|
|
48
|
+
const handleSelect = useCallback(
|
|
49
|
+
(item: SearchItem): void => {
|
|
50
|
+
setSessionConfig({ ...sessionConfig, model: item.id });
|
|
51
|
+
appendMessage('system', `Selected model: ${item.label}`);
|
|
52
|
+
if (dialogClearRef.current !== null) {
|
|
53
|
+
dialogClearRef.current();
|
|
54
|
+
}
|
|
55
|
+
focusInput();
|
|
56
|
+
},
|
|
57
|
+
[sessionConfig, setSessionConfig, appendMessage, focusInput],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const modal = useMemo(
|
|
61
|
+
() => (
|
|
62
|
+
<SearchSelectModal
|
|
63
|
+
title="Search Models"
|
|
64
|
+
noun="models"
|
|
65
|
+
items={modalItems}
|
|
66
|
+
alphabetical
|
|
67
|
+
footerHint="Tab to switch modes"
|
|
68
|
+
onClose={handleClose}
|
|
69
|
+
onSelect={handleSelect}
|
|
70
|
+
theme={theme}
|
|
71
|
+
/>
|
|
72
|
+
),
|
|
73
|
+
[modalItems, handleClose, handleSelect, theme],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const cleanup = register([
|
|
78
|
+
{
|
|
79
|
+
name: '/model',
|
|
80
|
+
title: 'Select Model',
|
|
81
|
+
category: 'configuration',
|
|
82
|
+
onExecute: async (dialog) => {
|
|
83
|
+
const result = await fetchModelItems();
|
|
84
|
+
if (result.messages !== undefined && result.messages.length > 0) {
|
|
85
|
+
appendMessage('system', result.messages.join('\n'));
|
|
86
|
+
}
|
|
87
|
+
if (result.items.length === 0) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
dialogClearRef.current = dialog.clear;
|
|
92
|
+
setModalItems(result.items);
|
|
93
|
+
dialog.replace(modal);
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
return cleanup;
|
|
99
|
+
}, [register, fetchModelItems, appendMessage, modal]);
|
|
100
|
+
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { useCommand } from '../uicontext';
|
|
9
|
+
import { SearchSelectModal } from '../ui/modals';
|
|
10
|
+
import type { SearchItem } from '../ui/modals/types';
|
|
11
|
+
import type { SessionConfig } from '../features/config';
|
|
12
|
+
import type { ThemeDefinition } from '../features/theme';
|
|
13
|
+
|
|
14
|
+
interface ProviderCommandProps {
|
|
15
|
+
readonly fetchProviderItems: () => Promise<{
|
|
16
|
+
items: SearchItem[];
|
|
17
|
+
messages?: string[];
|
|
18
|
+
}>;
|
|
19
|
+
readonly sessionConfig: SessionConfig;
|
|
20
|
+
readonly setSessionConfig: (config: SessionConfig) => void;
|
|
21
|
+
readonly appendMessage: (
|
|
22
|
+
role: 'user' | 'model' | 'system',
|
|
23
|
+
text: string,
|
|
24
|
+
) => string;
|
|
25
|
+
readonly theme: ThemeDefinition;
|
|
26
|
+
readonly focusInput: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ProviderCommand({
|
|
30
|
+
fetchProviderItems,
|
|
31
|
+
sessionConfig,
|
|
32
|
+
setSessionConfig,
|
|
33
|
+
appendMessage,
|
|
34
|
+
theme,
|
|
35
|
+
focusInput,
|
|
36
|
+
}: ProviderCommandProps): React.ReactNode | null {
|
|
37
|
+
const { register } = useCommand();
|
|
38
|
+
const dialogClearRef = useRef<(() => void) | null>(null);
|
|
39
|
+
const [modalItems, setModalItems] = useState<SearchItem[]>([]);
|
|
40
|
+
|
|
41
|
+
const handleClose = useCallback((): void => {
|
|
42
|
+
if (dialogClearRef.current !== null) {
|
|
43
|
+
dialogClearRef.current();
|
|
44
|
+
}
|
|
45
|
+
focusInput();
|
|
46
|
+
}, [focusInput]);
|
|
47
|
+
|
|
48
|
+
const handleSelect = useCallback(
|
|
49
|
+
(item: SearchItem): void => {
|
|
50
|
+
const id = item.id.toLowerCase();
|
|
51
|
+
setSessionConfig({ ...sessionConfig, provider: id });
|
|
52
|
+
appendMessage('system', `Selected provider: ${item.label}`);
|
|
53
|
+
if (dialogClearRef.current !== null) {
|
|
54
|
+
dialogClearRef.current();
|
|
55
|
+
}
|
|
56
|
+
focusInput();
|
|
57
|
+
},
|
|
58
|
+
[sessionConfig, setSessionConfig, appendMessage, focusInput],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const modal = useMemo(
|
|
62
|
+
() => (
|
|
63
|
+
<SearchSelectModal
|
|
64
|
+
title="Select Provider"
|
|
65
|
+
noun="providers"
|
|
66
|
+
items={modalItems}
|
|
67
|
+
alphabetical
|
|
68
|
+
footerHint="Tab to switch modes"
|
|
69
|
+
onClose={handleClose}
|
|
70
|
+
onSelect={handleSelect}
|
|
71
|
+
theme={theme}
|
|
72
|
+
/>
|
|
73
|
+
),
|
|
74
|
+
[modalItems, handleClose, handleSelect, theme],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const cleanup = register([
|
|
79
|
+
{
|
|
80
|
+
name: '/provider',
|
|
81
|
+
title: 'Select Provider',
|
|
82
|
+
category: 'configuration',
|
|
83
|
+
onExecute: async (dialog) => {
|
|
84
|
+
const result = await fetchProviderItems();
|
|
85
|
+
if (result.messages !== undefined && result.messages.length > 0) {
|
|
86
|
+
appendMessage('system', result.messages.join('\n'));
|
|
87
|
+
}
|
|
88
|
+
if (result.items.length === 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
dialogClearRef.current = dialog.clear;
|
|
93
|
+
setModalItems(result.items);
|
|
94
|
+
dialog.replace(modal);
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
return cleanup;
|
|
100
|
+
}, [register, fetchProviderItems, appendMessage, modal]);
|
|
101
|
+
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import { useCommand } from '../uicontext';
|
|
3
|
+
import { ThemeModal } from '../ui/modals';
|
|
4
|
+
import type { ThemeDefinition } from '../features/theme';
|
|
5
|
+
|
|
6
|
+
interface ThemeCommandProps {
|
|
7
|
+
readonly themes: ThemeDefinition[];
|
|
8
|
+
readonly currentTheme: ThemeDefinition;
|
|
9
|
+
readonly onThemeSelect: (theme: ThemeDefinition) => void;
|
|
10
|
+
readonly appendMessage: (
|
|
11
|
+
role: 'user' | 'model' | 'system',
|
|
12
|
+
text: string,
|
|
13
|
+
) => string;
|
|
14
|
+
readonly focusInput: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ThemeCommand({
|
|
18
|
+
themes,
|
|
19
|
+
currentTheme,
|
|
20
|
+
onThemeSelect,
|
|
21
|
+
appendMessage,
|
|
22
|
+
focusInput,
|
|
23
|
+
}: ThemeCommandProps): React.ReactNode | null {
|
|
24
|
+
const { register } = useCommand();
|
|
25
|
+
const dialogClearRef = useRef<(() => void) | null>(null);
|
|
26
|
+
|
|
27
|
+
const handleClose = useCallback((): void => {
|
|
28
|
+
if (dialogClearRef.current !== null) {
|
|
29
|
+
dialogClearRef.current();
|
|
30
|
+
}
|
|
31
|
+
focusInput();
|
|
32
|
+
}, [focusInput]);
|
|
33
|
+
|
|
34
|
+
const handleSelect = useCallback(
|
|
35
|
+
(theme: ThemeDefinition): void => {
|
|
36
|
+
onThemeSelect(theme);
|
|
37
|
+
appendMessage('system', `Theme set to ${theme.name}`);
|
|
38
|
+
},
|
|
39
|
+
[onThemeSelect, appendMessage],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const modal = useMemo(
|
|
43
|
+
() => (
|
|
44
|
+
<ThemeModal
|
|
45
|
+
themes={themes}
|
|
46
|
+
current={currentTheme}
|
|
47
|
+
onClose={handleClose}
|
|
48
|
+
onSelect={handleSelect}
|
|
49
|
+
/>
|
|
50
|
+
),
|
|
51
|
+
[themes, currentTheme, handleClose, handleSelect],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const cleanup = register([
|
|
56
|
+
{
|
|
57
|
+
name: '/theme',
|
|
58
|
+
title: 'Select Theme',
|
|
59
|
+
category: 'appearance',
|
|
60
|
+
onExecute: (dialog) => {
|
|
61
|
+
dialogClearRef.current = dialog.clear;
|
|
62
|
+
dialog.replace(modal);
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
return cleanup;
|
|
68
|
+
}, [register, modal]);
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { TextareaRenderable } from '@vybestack/opentui-core';
|
|
2
|
+
import { useCallback, useEffect, useRef, type RefObject } from 'react';
|
|
3
|
+
import type { PersistentHistoryService } from './persistentHistory';
|
|
4
|
+
|
|
5
|
+
type Direction = 'up' | 'down';
|
|
6
|
+
|
|
7
|
+
export interface UsePromptHistoryOptions {
|
|
8
|
+
/** Optional persistent history service for cross-session history */
|
|
9
|
+
persistentHistory?: PersistentHistoryService | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Calculate which line the cursor is on (0-indexed) based on cursor offset
|
|
14
|
+
*/
|
|
15
|
+
function getCursorLine(text: string, cursorOffset: number): number {
|
|
16
|
+
const textBeforeCursor = text.slice(0, cursorOffset);
|
|
17
|
+
const newlineMatches = textBeforeCursor.match(/\n/g);
|
|
18
|
+
return newlineMatches ? newlineMatches.length : 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the total number of lines in text (1-indexed count)
|
|
23
|
+
*/
|
|
24
|
+
function getLineCount(text: string): number {
|
|
25
|
+
const newlineMatches = text.match(/\n/g);
|
|
26
|
+
return (newlineMatches ? newlineMatches.length : 0) + 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function usePromptHistory(
|
|
30
|
+
textareaRef: RefObject<TextareaRenderable | null>,
|
|
31
|
+
options?: UsePromptHistoryOptions,
|
|
32
|
+
): {
|
|
33
|
+
record: (prompt: string) => void;
|
|
34
|
+
handleHistoryKey: (direction: Direction) => boolean;
|
|
35
|
+
} {
|
|
36
|
+
// Session entries are stored oldest-first for easy navigation
|
|
37
|
+
const sessionEntries = useRef<string[]>([]);
|
|
38
|
+
// Combined history: persistent (newest-first reversed) + session entries
|
|
39
|
+
const combinedEntries = useRef<string[]>([]);
|
|
40
|
+
const index = useRef<number>(0);
|
|
41
|
+
const lastKey = useRef<{ direction: Direction; time: number } | null>(null);
|
|
42
|
+
const persistentHistoryRef = useRef(options?.persistentHistory);
|
|
43
|
+
// Draft text saved when user starts navigating history
|
|
44
|
+
const draft = useRef<string | null>(null);
|
|
45
|
+
// Track if we're currently navigating history
|
|
46
|
+
const isNavigating = useRef<boolean>(false);
|
|
47
|
+
|
|
48
|
+
// Keep ref in sync
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
persistentHistoryRef.current = options?.persistentHistory;
|
|
51
|
+
}, [options?.persistentHistory]);
|
|
52
|
+
|
|
53
|
+
// Load persistent history on mount or when service changes
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const persistent = options?.persistentHistory;
|
|
56
|
+
if (persistent) {
|
|
57
|
+
// Persistent history is newest-first, reverse it for our oldest-first array
|
|
58
|
+
const persistentEntries = [...persistent.getHistory()].reverse();
|
|
59
|
+
combinedEntries.current = [
|
|
60
|
+
...persistentEntries,
|
|
61
|
+
...sessionEntries.current,
|
|
62
|
+
];
|
|
63
|
+
index.current = combinedEntries.current.length;
|
|
64
|
+
} else {
|
|
65
|
+
combinedEntries.current = [...sessionEntries.current];
|
|
66
|
+
index.current = combinedEntries.current.length;
|
|
67
|
+
}
|
|
68
|
+
}, [options?.persistentHistory]);
|
|
69
|
+
|
|
70
|
+
const record = useCallback((prompt: string) => {
|
|
71
|
+
// Add to session entries
|
|
72
|
+
sessionEntries.current.push(prompt);
|
|
73
|
+
|
|
74
|
+
// Update combined entries
|
|
75
|
+
const persistent = persistentHistoryRef.current;
|
|
76
|
+
if (persistent) {
|
|
77
|
+
const persistentEntries = [...persistent.getHistory()].reverse();
|
|
78
|
+
combinedEntries.current = [
|
|
79
|
+
...persistentEntries,
|
|
80
|
+
...sessionEntries.current,
|
|
81
|
+
];
|
|
82
|
+
} else {
|
|
83
|
+
combinedEntries.current = [...sessionEntries.current];
|
|
84
|
+
}
|
|
85
|
+
index.current = combinedEntries.current.length;
|
|
86
|
+
|
|
87
|
+
// Reset navigation state
|
|
88
|
+
draft.current = null;
|
|
89
|
+
isNavigating.current = false;
|
|
90
|
+
|
|
91
|
+
// Record to persistent storage (async, fire-and-forget)
|
|
92
|
+
if (persistent) {
|
|
93
|
+
void persistent.record(prompt);
|
|
94
|
+
}
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const applyEntry = useCallback(
|
|
98
|
+
(direction: Direction) => {
|
|
99
|
+
if (combinedEntries.current.length === 0) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
if (textareaRef.current == null) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const currentText = textareaRef.current.plainText;
|
|
107
|
+
const cursorOffset = textareaRef.current.cursorOffset;
|
|
108
|
+
const cursorLine = getCursorLine(currentText, cursorOffset);
|
|
109
|
+
const totalLines = getLineCount(currentText);
|
|
110
|
+
|
|
111
|
+
// Check cursor position requirement:
|
|
112
|
+
// - For "up": cursor must be on the first line (line 0)
|
|
113
|
+
// - For "down": cursor must be on the last line
|
|
114
|
+
if (direction === 'up' && cursorLine !== 0) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
if (direction === 'down' && cursorLine !== totalLines - 1) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Save draft when starting to navigate up
|
|
122
|
+
if (!isNavigating.current && direction === 'up') {
|
|
123
|
+
draft.current = currentText;
|
|
124
|
+
isNavigating.current = true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (direction === 'up') {
|
|
128
|
+
index.current = Math.max(0, index.current - 1);
|
|
129
|
+
} else {
|
|
130
|
+
index.current = Math.min(
|
|
131
|
+
combinedEntries.current.length,
|
|
132
|
+
index.current + 1,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// When navigating down past the end, restore the draft
|
|
137
|
+
if (index.current >= combinedEntries.current.length) {
|
|
138
|
+
const value = draft.current ?? '';
|
|
139
|
+
textareaRef.current.setText(value);
|
|
140
|
+
textareaRef.current.cursorOffset = value.length;
|
|
141
|
+
// Reset navigation state when back at draft
|
|
142
|
+
isNavigating.current = false;
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const value = combinedEntries.current[index.current] ?? '';
|
|
147
|
+
textareaRef.current.setText(value);
|
|
148
|
+
textareaRef.current.cursorOffset = value.length;
|
|
149
|
+
return true;
|
|
150
|
+
},
|
|
151
|
+
[textareaRef],
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const handleHistoryKey = useCallback(
|
|
155
|
+
(direction: Direction): boolean => {
|
|
156
|
+
// Down arrow: single press when navigating (on last line)
|
|
157
|
+
if (direction === 'down' && isNavigating.current) {
|
|
158
|
+
return applyEntry(direction);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Up arrow: requires double-tap
|
|
162
|
+
const now = Date.now();
|
|
163
|
+
if (
|
|
164
|
+
lastKey.current?.direction === direction &&
|
|
165
|
+
now - lastKey.current.time < 400
|
|
166
|
+
) {
|
|
167
|
+
const applied = applyEntry(direction);
|
|
168
|
+
lastKey.current = null;
|
|
169
|
+
return applied;
|
|
170
|
+
}
|
|
171
|
+
lastKey.current = { direction, time: now };
|
|
172
|
+
return false;
|
|
173
|
+
},
|
|
174
|
+
[applyEntry],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
return { record, handleHistoryKey };
|
|
178
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Logger,
|
|
3
|
+
MessageSenderType,
|
|
4
|
+
Storage,
|
|
5
|
+
} from '@vybestack/llxprt-code-core';
|
|
6
|
+
import { getLogger } from '../../lib/logger';
|
|
7
|
+
|
|
8
|
+
const debug = getLogger('nui:persistent-history');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Service for persistent prompt history that shares data with llxprt-code.
|
|
12
|
+
* History is stored in ~/.llxprt/tmp/{project-hash}/logs.json
|
|
13
|
+
*/
|
|
14
|
+
export class PersistentHistoryService {
|
|
15
|
+
private logger: Logger | null = null;
|
|
16
|
+
private initialized = false;
|
|
17
|
+
private cachedHistory: string[] = [];
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly workingDir: string,
|
|
21
|
+
private readonly sessionId: string,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Initialize the history service. Must be called before using other methods.
|
|
26
|
+
*/
|
|
27
|
+
async initialize(): Promise<void> {
|
|
28
|
+
if (this.initialized) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const storage = new Storage(this.workingDir);
|
|
34
|
+
this.logger = new Logger(this.sessionId, storage);
|
|
35
|
+
await this.logger.initialize();
|
|
36
|
+
|
|
37
|
+
// Load existing history
|
|
38
|
+
this.cachedHistory = await this.logger.getPreviousUserMessages();
|
|
39
|
+
debug.debug('Loaded history', 'count:', this.cachedHistory.length);
|
|
40
|
+
|
|
41
|
+
this.initialized = true;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
debug.error('Failed to initialize persistent history:', String(err));
|
|
44
|
+
this.initialized = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Record a new prompt to persistent storage.
|
|
50
|
+
*/
|
|
51
|
+
async record(prompt: string): Promise<void> {
|
|
52
|
+
if (!this.initialized || !this.logger) {
|
|
53
|
+
debug.warn('Cannot record: history service not initialized');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await this.logger.logMessage(MessageSenderType.USER, prompt);
|
|
59
|
+
// Add to cache (at the beginning since getPreviousUserMessages returns newest first)
|
|
60
|
+
this.cachedHistory.unshift(prompt);
|
|
61
|
+
debug.debug('Recorded prompt to history');
|
|
62
|
+
} catch (err) {
|
|
63
|
+
debug.error('Failed to record prompt:', String(err));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get all previous user messages, newest first.
|
|
69
|
+
*/
|
|
70
|
+
getHistory(): string[] {
|
|
71
|
+
return this.cachedHistory;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get the number of history entries.
|
|
76
|
+
*/
|
|
77
|
+
get count(): number {
|
|
78
|
+
return this.cachedHistory.length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Close the history service.
|
|
83
|
+
*/
|
|
84
|
+
close(): void {
|
|
85
|
+
if (this.logger) {
|
|
86
|
+
this.logger.close();
|
|
87
|
+
this.logger = null;
|
|
88
|
+
}
|
|
89
|
+
this.initialized = false;
|
|
90
|
+
this.cachedHistory = [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a persistent history service for the given working directory.
|
|
96
|
+
*/
|
|
97
|
+
export function createPersistentHistory(
|
|
98
|
+
workingDir: string,
|
|
99
|
+
sessionId: string,
|
|
100
|
+
): PersistentHistoryService {
|
|
101
|
+
return new PersistentHistoryService(workingDir, sessionId);
|
|
102
|
+
}
|