@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,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
+ }