@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,85 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { renderHook, act } from '@testing-library/react';
|
|
3
|
+
import { useSessionManager } from './useSessionManager';
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import * as os from 'node:os';
|
|
7
|
+
|
|
8
|
+
describe('useSessionManager', () => {
|
|
9
|
+
let tempDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nui-session-test-'));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('initial state', () => {
|
|
20
|
+
it('should start with null session and idle status', () => {
|
|
21
|
+
const { result } = renderHook(() => useSessionManager());
|
|
22
|
+
|
|
23
|
+
expect(result.current.session).toBeNull();
|
|
24
|
+
expect(result.current.status).toBe('idle');
|
|
25
|
+
expect(result.current.error).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should return false for hasSession initially', () => {
|
|
29
|
+
const { result } = renderHook(() => useSessionManager());
|
|
30
|
+
|
|
31
|
+
expect(result.current.hasSession).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('destroySession', () => {
|
|
36
|
+
it('should reset state to idle', () => {
|
|
37
|
+
const { result } = renderHook(() => useSessionManager());
|
|
38
|
+
|
|
39
|
+
act(() => {
|
|
40
|
+
result.current.destroySession();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(result.current.session).toBeNull();
|
|
44
|
+
expect(result.current.status).toBe('idle');
|
|
45
|
+
expect(result.current.error).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('createSession', () => {
|
|
50
|
+
it('should set status to initializing when called', () => {
|
|
51
|
+
const { result } = renderHook(() => useSessionManager());
|
|
52
|
+
|
|
53
|
+
// Start the async operation but don't await it yet
|
|
54
|
+
act(() => {
|
|
55
|
+
void result.current.createSession({
|
|
56
|
+
model: 'gemini-2.5-flash',
|
|
57
|
+
workingDir: tempDir,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// During initialization, status should be initializing
|
|
62
|
+
expect(result.current.status).toBe('initializing');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Session creation can be slow on Windows CI
|
|
66
|
+
it(
|
|
67
|
+
'should complete session creation with result',
|
|
68
|
+
{ timeout: 15000 },
|
|
69
|
+
async () => {
|
|
70
|
+
const { result } = renderHook(() => useSessionManager());
|
|
71
|
+
|
|
72
|
+
await act(async () => {
|
|
73
|
+
await result.current.createSession({
|
|
74
|
+
model: 'gemini-2.5-flash',
|
|
75
|
+
workingDir: tempDir,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// After completion, status should be either ready or error (not initializing)
|
|
80
|
+
expect(result.current.status).not.toBe('initializing');
|
|
81
|
+
expect(['ready', 'error']).toContain(result.current.status);
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useCallback, useState, useRef, useEffect } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
ConfigSession,
|
|
4
|
+
ConfigSessionOptions,
|
|
5
|
+
} from '../features/config/configSession';
|
|
6
|
+
import { createConfigSession } from '../features/config/configSession';
|
|
7
|
+
import { getLogger } from '../lib/logger';
|
|
8
|
+
|
|
9
|
+
export type SessionStatus = 'idle' | 'initializing' | 'ready' | 'error';
|
|
10
|
+
|
|
11
|
+
export interface UseSessionManagerReturn {
|
|
12
|
+
session: ConfigSession | null;
|
|
13
|
+
sessionOptions: ConfigSessionOptions | null;
|
|
14
|
+
status: SessionStatus;
|
|
15
|
+
error: string | null;
|
|
16
|
+
hasSession: boolean;
|
|
17
|
+
createSession: (options: ConfigSessionOptions) => Promise<void>;
|
|
18
|
+
destroySession: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const logger = getLogger('nui:session-manager');
|
|
22
|
+
|
|
23
|
+
export function useSessionManager(): UseSessionManagerReturn {
|
|
24
|
+
const [session, setSession] = useState<ConfigSession | null>(null);
|
|
25
|
+
const [sessionOptions, setSessionOptions] =
|
|
26
|
+
useState<ConfigSessionOptions | null>(null);
|
|
27
|
+
const [status, setStatus] = useState<SessionStatus>('idle');
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
const sessionRef = useRef<ConfigSession | null>(null);
|
|
30
|
+
|
|
31
|
+
const destroySession = useCallback(() => {
|
|
32
|
+
if (sessionRef.current) {
|
|
33
|
+
logger.debug('Disposing existing session');
|
|
34
|
+
sessionRef.current.dispose();
|
|
35
|
+
sessionRef.current = null;
|
|
36
|
+
}
|
|
37
|
+
setSession(null);
|
|
38
|
+
setSessionOptions(null);
|
|
39
|
+
setStatus('idle');
|
|
40
|
+
setError(null);
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
const createSession = useCallback(
|
|
44
|
+
async (options: ConfigSessionOptions): Promise<void> => {
|
|
45
|
+
logger.debug('Creating new session', {
|
|
46
|
+
model: options.model,
|
|
47
|
+
provider: options.provider,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Dispose existing session first
|
|
51
|
+
if (sessionRef.current) {
|
|
52
|
+
sessionRef.current.dispose();
|
|
53
|
+
sessionRef.current = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setStatus('initializing');
|
|
57
|
+
setError(null);
|
|
58
|
+
setSession(null);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const newSession = createConfigSession(options);
|
|
62
|
+
await newSession.initialize();
|
|
63
|
+
|
|
64
|
+
sessionRef.current = newSession;
|
|
65
|
+
setSession(newSession);
|
|
66
|
+
setSessionOptions(options);
|
|
67
|
+
setStatus('ready');
|
|
68
|
+
|
|
69
|
+
const registry = newSession.config.getToolRegistry();
|
|
70
|
+
const tools = registry.getFunctionDeclarations();
|
|
71
|
+
logger.debug('Session ready', { toolCount: tools.length });
|
|
72
|
+
} catch (err) {
|
|
73
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
74
|
+
logger.error('Session initialization failed', { error: message });
|
|
75
|
+
setError(message);
|
|
76
|
+
setStatus('error');
|
|
77
|
+
setSession(null);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
[],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Cleanup on unmount
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
return () => {
|
|
86
|
+
if (sessionRef.current) {
|
|
87
|
+
sessionRef.current.dispose();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
session,
|
|
94
|
+
sessionOptions,
|
|
95
|
+
status,
|
|
96
|
+
error,
|
|
97
|
+
hasSession: session !== null && status === 'ready',
|
|
98
|
+
createSession,
|
|
99
|
+
destroySession,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
|
2
|
+
import { useCallback, useRef } from 'react';
|
|
3
|
+
import type { ConfigSession } from '../features/config/configSession';
|
|
4
|
+
import type { ToolConfirmationEvent } from '../features/config';
|
|
5
|
+
import type { ToolCall } from './useChatStore';
|
|
6
|
+
import { useStreamingResponder } from './useStreamingResponder';
|
|
7
|
+
import type { ScheduleFn } from './useToolScheduler';
|
|
8
|
+
|
|
9
|
+
interface UseStreamingLifecycleResult {
|
|
10
|
+
streamRunId: RefObject<number>;
|
|
11
|
+
mountedRef: RefObject<boolean>;
|
|
12
|
+
abortRef: RefObject<AbortController | null>;
|
|
13
|
+
cancelStreaming: () => void;
|
|
14
|
+
startStreamingResponder: (
|
|
15
|
+
prompt: string,
|
|
16
|
+
session: ConfigSession | null,
|
|
17
|
+
) => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useStreamingLifecycle(
|
|
21
|
+
appendMessage: (
|
|
22
|
+
role: 'user' | 'model' | 'thinking' | 'system',
|
|
23
|
+
text: string,
|
|
24
|
+
) => string,
|
|
25
|
+
appendToMessage: (id: string, text: string) => void,
|
|
26
|
+
appendToolCall: (
|
|
27
|
+
callId: string,
|
|
28
|
+
name: string,
|
|
29
|
+
params: Record<string, unknown>,
|
|
30
|
+
) => string,
|
|
31
|
+
updateToolCall: (
|
|
32
|
+
callId: string,
|
|
33
|
+
update: Partial<Omit<ToolCall, 'id' | 'kind' | 'callId'>>,
|
|
34
|
+
) => void,
|
|
35
|
+
setResponderWordCount: Dispatch<SetStateAction<number>>,
|
|
36
|
+
setStreamState: Dispatch<SetStateAction<'idle' | 'busy'>>,
|
|
37
|
+
scheduleTools: ScheduleFn,
|
|
38
|
+
onConfirmationNeeded?: (event: ToolConfirmationEvent) => void,
|
|
39
|
+
): UseStreamingLifecycleResult {
|
|
40
|
+
const streamRunId = useRef(0);
|
|
41
|
+
const mountedRef = useRef(true);
|
|
42
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
43
|
+
|
|
44
|
+
const startStreamingResponder = useStreamingResponder(
|
|
45
|
+
appendMessage,
|
|
46
|
+
appendToMessage,
|
|
47
|
+
appendToolCall,
|
|
48
|
+
updateToolCall,
|
|
49
|
+
setResponderWordCount,
|
|
50
|
+
setStreamState,
|
|
51
|
+
streamRunId,
|
|
52
|
+
mountedRef,
|
|
53
|
+
abortRef,
|
|
54
|
+
scheduleTools,
|
|
55
|
+
onConfirmationNeeded,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const cancelStreaming = useCallback(() => {
|
|
59
|
+
streamRunId.current += 1;
|
|
60
|
+
abortRef.current?.abort();
|
|
61
|
+
setStreamState('idle');
|
|
62
|
+
}, [setStreamState]);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
streamRunId,
|
|
66
|
+
mountedRef,
|
|
67
|
+
abortRef,
|
|
68
|
+
cancelStreaming,
|
|
69
|
+
startStreamingResponder,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import type { Dispatch, SetStateAction } from 'react';
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
|
+
import type { Role, StreamState, ToolCall } from './useChatStore';
|
|
4
|
+
import type { ConfigSession } from '../features/config/configSession';
|
|
5
|
+
import type { AdapterEvent, ToolConfirmationEvent } from '../features/config';
|
|
6
|
+
import { sendMessageWithSession } from '../features/config';
|
|
7
|
+
import type {
|
|
8
|
+
ToolCallRequestInfo,
|
|
9
|
+
CompletedToolCall,
|
|
10
|
+
} from '@vybestack/llxprt-code-core';
|
|
11
|
+
import type { ScheduleFn } from './useToolScheduler';
|
|
12
|
+
import { getLogger } from '../lib/logger';
|
|
13
|
+
|
|
14
|
+
const logger = getLogger('nui:streaming-responder');
|
|
15
|
+
|
|
16
|
+
type StateSetter<T> = Dispatch<SetStateAction<T>>;
|
|
17
|
+
|
|
18
|
+
interface RefHandle<T> {
|
|
19
|
+
current: T;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface StreamContext {
|
|
23
|
+
modelMessageId: string | null;
|
|
24
|
+
thinkingMessageId: string | null;
|
|
25
|
+
/** Track tool calls by their backend callId */
|
|
26
|
+
toolCalls: Map<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function countWords(text: string): number {
|
|
30
|
+
const matches = text.trim().match(/\S+/g);
|
|
31
|
+
return matches ? matches.length : 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type ToolCallUpdate = Partial<Omit<ToolCall, 'id' | 'kind' | 'callId'>>;
|
|
35
|
+
|
|
36
|
+
function handleAdapterEvent(
|
|
37
|
+
event: AdapterEvent,
|
|
38
|
+
context: StreamContext,
|
|
39
|
+
appendMessage: (role: Role, text: string) => string,
|
|
40
|
+
appendToMessage: (id: string, text: string) => void,
|
|
41
|
+
appendToolCall: (
|
|
42
|
+
callId: string,
|
|
43
|
+
name: string,
|
|
44
|
+
params: Record<string, unknown>,
|
|
45
|
+
) => string,
|
|
46
|
+
updateToolCall: (callId: string, update: ToolCallUpdate) => void,
|
|
47
|
+
setResponderWordCount: StateSetter<number>,
|
|
48
|
+
scheduleTools: ScheduleFn,
|
|
49
|
+
signal: AbortSignal,
|
|
50
|
+
onConfirmationNeeded?: (event: ToolConfirmationEvent) => void,
|
|
51
|
+
): StreamContext {
|
|
52
|
+
if (event.type === 'text_delta') {
|
|
53
|
+
const text = event.text;
|
|
54
|
+
// Skip empty or whitespace-only text when starting a new message
|
|
55
|
+
if (context.modelMessageId === null) {
|
|
56
|
+
if (text.trim() === '') {
|
|
57
|
+
return context;
|
|
58
|
+
}
|
|
59
|
+
const id = appendMessage('model', text);
|
|
60
|
+
setResponderWordCount((count) => count + countWords(text));
|
|
61
|
+
return { ...context, modelMessageId: id };
|
|
62
|
+
}
|
|
63
|
+
appendToMessage(context.modelMessageId, text);
|
|
64
|
+
setResponderWordCount((count) => count + countWords(text));
|
|
65
|
+
return context;
|
|
66
|
+
}
|
|
67
|
+
if (event.type === 'thinking_delta') {
|
|
68
|
+
const text = event.text;
|
|
69
|
+
// Skip empty or whitespace-only text when starting a new message
|
|
70
|
+
if (context.thinkingMessageId === null) {
|
|
71
|
+
if (text.trim() === '') {
|
|
72
|
+
return context;
|
|
73
|
+
}
|
|
74
|
+
const id = appendMessage('thinking', text);
|
|
75
|
+
setResponderWordCount((count) => count + countWords(text));
|
|
76
|
+
return { ...context, thinkingMessageId: id };
|
|
77
|
+
}
|
|
78
|
+
appendToMessage(context.thinkingMessageId, text);
|
|
79
|
+
setResponderWordCount((count) => count + countWords(text));
|
|
80
|
+
return context;
|
|
81
|
+
}
|
|
82
|
+
if (event.type === 'tool_pending') {
|
|
83
|
+
// Create a new ToolCall entry in UI
|
|
84
|
+
const entryId = appendToolCall(event.id, event.name, event.params);
|
|
85
|
+
const newToolCalls = new Map(context.toolCalls);
|
|
86
|
+
newToolCalls.set(event.id, entryId);
|
|
87
|
+
|
|
88
|
+
// Schedule the tool call via the scheduler (handles confirmation flow)
|
|
89
|
+
const request: ToolCallRequestInfo = {
|
|
90
|
+
callId: event.id,
|
|
91
|
+
name: event.name,
|
|
92
|
+
args: event.params,
|
|
93
|
+
isClientInitiated: false,
|
|
94
|
+
prompt_id: `nui-${Date.now()}`,
|
|
95
|
+
};
|
|
96
|
+
scheduleTools(request, signal);
|
|
97
|
+
|
|
98
|
+
// Reset message IDs since model output may continue after tool
|
|
99
|
+
return {
|
|
100
|
+
modelMessageId: null,
|
|
101
|
+
thinkingMessageId: null,
|
|
102
|
+
toolCalls: newToolCalls,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (event.type === 'tool_result') {
|
|
106
|
+
// Update existing tool call with result
|
|
107
|
+
updateToolCall(event.id, {
|
|
108
|
+
status: event.success ? 'complete' : 'error',
|
|
109
|
+
output: event.output,
|
|
110
|
+
errorMessage: event.errorMessage,
|
|
111
|
+
});
|
|
112
|
+
return context;
|
|
113
|
+
}
|
|
114
|
+
if (event.type === 'tool_confirmation') {
|
|
115
|
+
// Update tool call with confirmation details
|
|
116
|
+
updateToolCall(event.id, {
|
|
117
|
+
status: 'confirming',
|
|
118
|
+
confirmation: {
|
|
119
|
+
confirmationType: event.confirmationType,
|
|
120
|
+
question: event.question,
|
|
121
|
+
preview: event.preview,
|
|
122
|
+
canAllowAlways: event.canAllowAlways,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
// Notify UI that confirmation is needed
|
|
126
|
+
if (onConfirmationNeeded) {
|
|
127
|
+
onConfirmationNeeded(event);
|
|
128
|
+
}
|
|
129
|
+
return context;
|
|
130
|
+
}
|
|
131
|
+
if (event.type === 'tool_cancelled') {
|
|
132
|
+
updateToolCall(event.id, { status: 'cancelled' });
|
|
133
|
+
return context;
|
|
134
|
+
}
|
|
135
|
+
if (event.type === 'error') {
|
|
136
|
+
appendMessage('system', `Error: ${event.message}`);
|
|
137
|
+
return context;
|
|
138
|
+
}
|
|
139
|
+
// Handle complete and unknown events - no action needed
|
|
140
|
+
return context;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type UseStreamingResponderFunction = (
|
|
144
|
+
prompt: string,
|
|
145
|
+
session: ConfigSession | null,
|
|
146
|
+
) => Promise<void>;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Callback to handle completed tools - sends responses back to the model
|
|
150
|
+
*/
|
|
151
|
+
export type OnToolsCompleteCallback = (
|
|
152
|
+
session: ConfigSession,
|
|
153
|
+
completedTools: CompletedToolCall[],
|
|
154
|
+
signal: AbortSignal,
|
|
155
|
+
) => Promise<void>;
|
|
156
|
+
|
|
157
|
+
export function useStreamingResponder(
|
|
158
|
+
appendMessage: (role: Role, text: string) => string,
|
|
159
|
+
appendToMessage: (id: string, text: string) => void,
|
|
160
|
+
appendToolCall: (
|
|
161
|
+
callId: string,
|
|
162
|
+
name: string,
|
|
163
|
+
params: Record<string, unknown>,
|
|
164
|
+
) => string,
|
|
165
|
+
updateToolCall: (
|
|
166
|
+
callId: string,
|
|
167
|
+
update: Partial<Omit<ToolCall, 'id' | 'kind' | 'callId'>>,
|
|
168
|
+
) => void,
|
|
169
|
+
setResponderWordCount: StateSetter<number>,
|
|
170
|
+
setStreamState: StateSetter<StreamState>,
|
|
171
|
+
streamRunId: RefHandle<number>,
|
|
172
|
+
mountedRef: RefHandle<boolean>,
|
|
173
|
+
abortRef: RefHandle<AbortController | null>,
|
|
174
|
+
scheduleTools: ScheduleFn,
|
|
175
|
+
onConfirmationNeeded?: (event: ToolConfirmationEvent) => void,
|
|
176
|
+
): UseStreamingResponderFunction {
|
|
177
|
+
return useCallback(
|
|
178
|
+
async (prompt: string, session: ConfigSession | null) => {
|
|
179
|
+
// Validate session exists
|
|
180
|
+
if (session === null) {
|
|
181
|
+
appendMessage(
|
|
182
|
+
'system',
|
|
183
|
+
'No active session. Load a profile first with /profile load <name>',
|
|
184
|
+
);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
streamRunId.current += 1;
|
|
189
|
+
const currentRun = streamRunId.current;
|
|
190
|
+
|
|
191
|
+
if (abortRef.current) {
|
|
192
|
+
abortRef.current.abort();
|
|
193
|
+
}
|
|
194
|
+
const controller = new AbortController();
|
|
195
|
+
abortRef.current = controller;
|
|
196
|
+
setStreamState('busy');
|
|
197
|
+
|
|
198
|
+
let context: StreamContext = {
|
|
199
|
+
modelMessageId: null,
|
|
200
|
+
thinkingMessageId: null,
|
|
201
|
+
toolCalls: new Map(),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
let hasScheduledTools = false;
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
// Stream messages from the model
|
|
208
|
+
for await (const event of sendMessageWithSession(
|
|
209
|
+
session,
|
|
210
|
+
prompt,
|
|
211
|
+
controller.signal,
|
|
212
|
+
)) {
|
|
213
|
+
if (!mountedRef.current || streamRunId.current !== currentRun) {
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
// Track if any tools were scheduled
|
|
217
|
+
if (event.type === 'tool_pending') {
|
|
218
|
+
hasScheduledTools = true;
|
|
219
|
+
}
|
|
220
|
+
context = handleAdapterEvent(
|
|
221
|
+
event,
|
|
222
|
+
context,
|
|
223
|
+
appendMessage,
|
|
224
|
+
appendToMessage,
|
|
225
|
+
appendToolCall,
|
|
226
|
+
updateToolCall,
|
|
227
|
+
setResponderWordCount,
|
|
228
|
+
scheduleTools,
|
|
229
|
+
controller.signal,
|
|
230
|
+
onConfirmationNeeded,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
// Tool execution is handled by the scheduler via callbacks
|
|
234
|
+
// The scheduler will call onAllToolCallsComplete when tools finish
|
|
235
|
+
// If tools were scheduled, DON'T set idle here - let the tool completion callback do it
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (!controller.signal.aborted) {
|
|
238
|
+
const message =
|
|
239
|
+
error instanceof Error ? error.message : String(error);
|
|
240
|
+
appendMessage('system', `Error: ${message}`);
|
|
241
|
+
}
|
|
242
|
+
} finally {
|
|
243
|
+
// Only set idle if no tools were scheduled (tools will set idle when complete)
|
|
244
|
+
// Also set idle if aborted or component unmounted
|
|
245
|
+
const wasAborted = controller.signal.aborted;
|
|
246
|
+
const isCurrent = streamRunId.current === currentRun;
|
|
247
|
+
|
|
248
|
+
if (
|
|
249
|
+
mountedRef.current &&
|
|
250
|
+
isCurrent &&
|
|
251
|
+
(wasAborted || !hasScheduledTools)
|
|
252
|
+
) {
|
|
253
|
+
setStreamState('idle');
|
|
254
|
+
}
|
|
255
|
+
// Only clear abortRef if no tools were scheduled
|
|
256
|
+
// If tools are scheduled, we need to keep the controller for continuation
|
|
257
|
+
if (abortRef.current === controller && !hasScheduledTools) {
|
|
258
|
+
abortRef.current = null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
[
|
|
263
|
+
appendMessage,
|
|
264
|
+
appendToMessage,
|
|
265
|
+
appendToolCall,
|
|
266
|
+
updateToolCall,
|
|
267
|
+
abortRef,
|
|
268
|
+
mountedRef,
|
|
269
|
+
setResponderWordCount,
|
|
270
|
+
setStreamState,
|
|
271
|
+
streamRunId,
|
|
272
|
+
scheduleTools,
|
|
273
|
+
onConfirmationNeeded,
|
|
274
|
+
],
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Continue streaming after tools complete - sends tool responses to model
|
|
280
|
+
* Returns true if new tools were scheduled, false if conversation turn is complete
|
|
281
|
+
*/
|
|
282
|
+
export async function continueStreamingAfterTools(
|
|
283
|
+
session: ConfigSession,
|
|
284
|
+
completedTools: CompletedToolCall[],
|
|
285
|
+
signal: AbortSignal,
|
|
286
|
+
appendMessage: (role: Role, text: string) => string,
|
|
287
|
+
appendToMessage: (id: string, text: string) => void,
|
|
288
|
+
appendToolCall: (
|
|
289
|
+
callId: string,
|
|
290
|
+
name: string,
|
|
291
|
+
params: Record<string, unknown>,
|
|
292
|
+
) => string,
|
|
293
|
+
updateToolCall: (
|
|
294
|
+
callId: string,
|
|
295
|
+
update: Partial<Omit<ToolCall, 'id' | 'kind' | 'callId'>>,
|
|
296
|
+
) => void,
|
|
297
|
+
setResponderWordCount: StateSetter<number>,
|
|
298
|
+
scheduleTools: ScheduleFn,
|
|
299
|
+
setStreamState: StateSetter<StreamState>,
|
|
300
|
+
onConfirmationNeeded?: (event: ToolConfirmationEvent) => void,
|
|
301
|
+
): Promise<boolean> {
|
|
302
|
+
logger.debug(
|
|
303
|
+
'continueStreamingAfterTools called',
|
|
304
|
+
'toolCount:',
|
|
305
|
+
completedTools.length,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
// Collect all response parts from completed tools
|
|
309
|
+
const responseParts = completedTools.flatMap(
|
|
310
|
+
(tool) => tool.response.responseParts,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
logger.debug(
|
|
314
|
+
'continueStreamingAfterTools: collected response parts',
|
|
315
|
+
'count:',
|
|
316
|
+
responseParts.length,
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
if (responseParts.length === 0) {
|
|
320
|
+
// No response parts to send - this might happen if all tools had validation errors
|
|
321
|
+
// Check if there were any errors we should report
|
|
322
|
+
const errors = completedTools
|
|
323
|
+
.filter((tool) => tool.response.error)
|
|
324
|
+
.map((tool) => tool.response.error?.message);
|
|
325
|
+
|
|
326
|
+
logger.debug(
|
|
327
|
+
'continueStreamingAfterTools: no response parts',
|
|
328
|
+
'errorCount:',
|
|
329
|
+
errors.length,
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
if (errors.length > 0) {
|
|
333
|
+
// Append error messages to chat so user can see what went wrong
|
|
334
|
+
for (const error of errors) {
|
|
335
|
+
if (error) {
|
|
336
|
+
appendMessage('system', `Tool error: ${error}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
setStreamState('idle');
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Send tool responses back to model
|
|
346
|
+
const client = session.getClient();
|
|
347
|
+
const promptId = `nui-continuation-${Date.now()}`;
|
|
348
|
+
const stream = client.sendMessageStream(responseParts, signal, promptId);
|
|
349
|
+
|
|
350
|
+
let context: StreamContext = {
|
|
351
|
+
modelMessageId: null,
|
|
352
|
+
thinkingMessageId: null,
|
|
353
|
+
toolCalls: new Map(),
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
let hasScheduledTools = false;
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
for await (const coreEvent of stream) {
|
|
360
|
+
if (signal.aborted) {
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
// Transform and handle the event
|
|
364
|
+
const { transformEvent } =
|
|
365
|
+
await import('../features/config/llxprtAdapter');
|
|
366
|
+
const event = transformEvent(coreEvent);
|
|
367
|
+
|
|
368
|
+
// Track if any tools were scheduled during continuation
|
|
369
|
+
if (event.type === 'tool_pending') {
|
|
370
|
+
hasScheduledTools = true;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
context = handleAdapterEvent(
|
|
374
|
+
event,
|
|
375
|
+
context,
|
|
376
|
+
appendMessage,
|
|
377
|
+
appendToMessage,
|
|
378
|
+
appendToolCall,
|
|
379
|
+
updateToolCall,
|
|
380
|
+
setResponderWordCount,
|
|
381
|
+
scheduleTools,
|
|
382
|
+
signal,
|
|
383
|
+
onConfirmationNeeded,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
} catch (error) {
|
|
387
|
+
// Handle errors during continuation streaming
|
|
388
|
+
if (!signal.aborted) {
|
|
389
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
390
|
+
appendMessage('system', `Continuation error: ${message}`);
|
|
391
|
+
}
|
|
392
|
+
} finally {
|
|
393
|
+
// Only set idle if no new tools were scheduled and not aborted
|
|
394
|
+
// If tools were scheduled, the scheduler will handle setting idle when they complete
|
|
395
|
+
if (!hasScheduledTools && !signal.aborted) {
|
|
396
|
+
setStreamState('idle');
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return hasScheduledTools;
|
|
401
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
setThemeSuggestions,
|
|
4
|
+
setProfileSuggestions,
|
|
5
|
+
} from '../features/completion';
|
|
6
|
+
import { listAvailableProfiles } from '../features/config';
|
|
7
|
+
import type { ThemeDefinition } from '../features/theme';
|
|
8
|
+
|
|
9
|
+
export function useSuggestionSetup(themes: ThemeDefinition[]): void {
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
setThemeSuggestions(
|
|
12
|
+
themes.map((entry) => ({ slug: entry.slug, name: entry.name })),
|
|
13
|
+
);
|
|
14
|
+
}, [themes]);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
listAvailableProfiles()
|
|
18
|
+
.then((profiles) => setProfileSuggestions(profiles))
|
|
19
|
+
.catch(() => {
|
|
20
|
+
return;
|
|
21
|
+
});
|
|
22
|
+
}, []);
|
|
23
|
+
}
|