@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
package/src/app.tsx
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ScrollBoxRenderable,
|
|
3
|
+
TextareaRenderable,
|
|
4
|
+
} from '@vybestack/opentui-core';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { useRenderer } from '@vybestack/opentui-react';
|
|
7
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
8
|
+
import type {
|
|
9
|
+
CompletedToolCall,
|
|
10
|
+
WaitingToolCall,
|
|
11
|
+
ToolCallConfirmationDetails,
|
|
12
|
+
} from '@vybestack/llxprt-code-core';
|
|
13
|
+
import { useCompletionManager } from './features/completion';
|
|
14
|
+
import { usePromptHistory } from './features/chat';
|
|
15
|
+
import { useThemeManager } from './features/theme';
|
|
16
|
+
import type { ThemeDefinition } from './features/theme';
|
|
17
|
+
import type { SessionConfig } from './features/config';
|
|
18
|
+
import { useChatStore } from './hooks/useChatStore';
|
|
19
|
+
import { useInputManager } from './hooks/useInputManager';
|
|
20
|
+
import { useScrollManagement } from './hooks/useScrollManagement';
|
|
21
|
+
import { useStreamingLifecycle } from './hooks/useStreamingLifecycle';
|
|
22
|
+
import { useSelectionClipboard } from './hooks/useSelectionClipboard';
|
|
23
|
+
import { useAppCommands } from './hooks/useAppCommands';
|
|
24
|
+
import { useSuggestionSetup } from './hooks/useSuggestionSetup';
|
|
25
|
+
import { useSessionManager } from './hooks/useSessionManager';
|
|
26
|
+
import { usePersistentHistory } from './hooks/usePersistentHistory';
|
|
27
|
+
import { useToolApproval } from './hooks/useToolApproval';
|
|
28
|
+
import {
|
|
29
|
+
useToolScheduler,
|
|
30
|
+
type TrackedToolCall,
|
|
31
|
+
type ScheduleFn,
|
|
32
|
+
} from './hooks/useToolScheduler';
|
|
33
|
+
import { continueStreamingAfterTools } from './hooks/useStreamingResponder';
|
|
34
|
+
import {
|
|
35
|
+
useEnterSubmit,
|
|
36
|
+
useFocusAndMount,
|
|
37
|
+
useSuggestionKeybindings,
|
|
38
|
+
useLineIdGenerator,
|
|
39
|
+
useHistoryNavigation,
|
|
40
|
+
} from './hooks/useKeyboardHandlers';
|
|
41
|
+
import {
|
|
42
|
+
ChatLayout,
|
|
43
|
+
type PendingApprovalState,
|
|
44
|
+
type ToolApprovalOutcome,
|
|
45
|
+
} from './ui/components/ChatLayout';
|
|
46
|
+
import { buildStatusLabel } from './ui/components/StatusBar';
|
|
47
|
+
import { CommandComponents } from './ui/components/CommandComponents';
|
|
48
|
+
import { Dialog, useDialog, Command, useCommand } from './uicontext';
|
|
49
|
+
import { useApprovalKeyboard } from './hooks/useApprovalKeyboard';
|
|
50
|
+
import { getLogger } from './lib/logger';
|
|
51
|
+
|
|
52
|
+
const logger = getLogger('nui:app');
|
|
53
|
+
|
|
54
|
+
/** Generate question text from confirmation details */
|
|
55
|
+
function getQuestionForConfirmation(
|
|
56
|
+
details: ToolCallConfirmationDetails,
|
|
57
|
+
): string {
|
|
58
|
+
switch (details.type) {
|
|
59
|
+
case 'edit':
|
|
60
|
+
return `Allow editing ${details.fileName}?`;
|
|
61
|
+
case 'exec':
|
|
62
|
+
return 'Allow executing this command?';
|
|
63
|
+
case 'mcp':
|
|
64
|
+
return `Allow MCP tool: ${details.serverName}/${details.toolName}?`;
|
|
65
|
+
case 'info':
|
|
66
|
+
return 'Confirm this action?';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Generate preview text from confirmation details */
|
|
71
|
+
function getPreviewForConfirmation(
|
|
72
|
+
details: ToolCallConfirmationDetails,
|
|
73
|
+
): string {
|
|
74
|
+
switch (details.type) {
|
|
75
|
+
case 'edit':
|
|
76
|
+
return details.fileDiff;
|
|
77
|
+
case 'exec':
|
|
78
|
+
return details.command;
|
|
79
|
+
case 'mcp':
|
|
80
|
+
return `Server: ${details.serverName}\nTool: ${details.toolDisplayName}`;
|
|
81
|
+
case 'info':
|
|
82
|
+
return details.prompt;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const HEADER_TEXT = "LLxprt Code - I'm here to help";
|
|
87
|
+
|
|
88
|
+
function AppInner(): React.ReactNode {
|
|
89
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
90
|
+
const textareaRef = useRef<TextareaRenderable | null>(null);
|
|
91
|
+
const scheduleRef = useRef<ScheduleFn | null>(null);
|
|
92
|
+
// Ref to access abortRef from useStreamingLifecycle (set after hook is called)
|
|
93
|
+
const abortRefContainer = useRef<{ current: AbortController | null }>({
|
|
94
|
+
current: null,
|
|
95
|
+
});
|
|
96
|
+
// Guard against concurrent continuation calls
|
|
97
|
+
const continuationInProgressRef = useRef(false);
|
|
98
|
+
const [sessionConfig, setSessionConfig] = useState<SessionConfig>({
|
|
99
|
+
provider: 'openai',
|
|
100
|
+
});
|
|
101
|
+
const { themes, theme, setThemeBySlug } = useThemeManager();
|
|
102
|
+
const renderer = useRenderer();
|
|
103
|
+
|
|
104
|
+
const { session, sessionOptions, createSession } = useSessionManager();
|
|
105
|
+
|
|
106
|
+
// Generate stable session ID for history (once per app instance)
|
|
107
|
+
const historySessionIdRef = useRef(`nui-${Date.now()}`);
|
|
108
|
+
|
|
109
|
+
// Initialize persistent history immediately using cwd, so history is available before profile load
|
|
110
|
+
const { service: persistentHistory } = usePersistentHistory({
|
|
111
|
+
workingDir: sessionOptions?.workingDir ?? process.cwd(),
|
|
112
|
+
sessionId: historySessionIdRef.current,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const dialog = useDialog();
|
|
116
|
+
const { trigger: triggerCommand } = useCommand();
|
|
117
|
+
const {
|
|
118
|
+
suggestions,
|
|
119
|
+
selectedIndex,
|
|
120
|
+
refresh: refreshCompletion,
|
|
121
|
+
clear: clearCompletion,
|
|
122
|
+
moveSelection,
|
|
123
|
+
applySelection,
|
|
124
|
+
} = useCompletionManager(textareaRef);
|
|
125
|
+
const { record: recordHistory, handleHistoryKey } = usePromptHistory(
|
|
126
|
+
textareaRef,
|
|
127
|
+
{ persistentHistory },
|
|
128
|
+
);
|
|
129
|
+
const makeLineId = useLineIdGenerator();
|
|
130
|
+
const {
|
|
131
|
+
entries,
|
|
132
|
+
appendMessage,
|
|
133
|
+
appendToMessage,
|
|
134
|
+
appendToolCall,
|
|
135
|
+
updateToolCall,
|
|
136
|
+
clearEntries,
|
|
137
|
+
promptCount,
|
|
138
|
+
setPromptCount,
|
|
139
|
+
responderWordCount,
|
|
140
|
+
setResponderWordCount,
|
|
141
|
+
streamState,
|
|
142
|
+
setStreamState,
|
|
143
|
+
} = useChatStore(makeLineId);
|
|
144
|
+
|
|
145
|
+
// Create a ref for queueApprovalFromScheduler to break circular dependency
|
|
146
|
+
const queueApprovalFromSchedulerRef = useRef<
|
|
147
|
+
(
|
|
148
|
+
callId: string,
|
|
149
|
+
toolName: string,
|
|
150
|
+
confirmationDetails: ToolCallConfirmationDetails,
|
|
151
|
+
) => void
|
|
152
|
+
>(() => {
|
|
153
|
+
/* placeholder - will be assigned later */
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Tool scheduler callbacks using refs to avoid circular dependencies
|
|
157
|
+
const onToolsComplete = useCallback(
|
|
158
|
+
async (completedTools: CompletedToolCall[]) => {
|
|
159
|
+
logger.debug(
|
|
160
|
+
'onToolsComplete called',
|
|
161
|
+
'toolCount:',
|
|
162
|
+
completedTools.length,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (!session || completedTools.length === 0) {
|
|
166
|
+
logger.debug(
|
|
167
|
+
'onToolsComplete: no session or empty tools, setting idle',
|
|
168
|
+
);
|
|
169
|
+
setStreamState('idle');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Guard against concurrent continuations - this can happen if multiple tool batches complete
|
|
174
|
+
if (continuationInProgressRef.current) {
|
|
175
|
+
logger.debug(
|
|
176
|
+
'onToolsComplete: skipping, continuation already in progress',
|
|
177
|
+
);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const signal = abortRefContainer.current.current?.signal;
|
|
182
|
+
const scheduleFn = scheduleRef.current;
|
|
183
|
+
|
|
184
|
+
logger.debug(
|
|
185
|
+
'onToolsComplete: checking signal',
|
|
186
|
+
'hasSignal:',
|
|
187
|
+
!!signal,
|
|
188
|
+
'aborted:',
|
|
189
|
+
signal?.aborted,
|
|
190
|
+
'hasScheduler:',
|
|
191
|
+
!!scheduleFn,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
if (signal && !signal.aborted && scheduleFn) {
|
|
195
|
+
continuationInProgressRef.current = true;
|
|
196
|
+
try {
|
|
197
|
+
logger.debug('onToolsComplete: starting continueStreamingAfterTools');
|
|
198
|
+
const hasMoreTools = await continueStreamingAfterTools(
|
|
199
|
+
session,
|
|
200
|
+
completedTools,
|
|
201
|
+
signal,
|
|
202
|
+
appendMessage,
|
|
203
|
+
appendToMessage,
|
|
204
|
+
appendToolCall,
|
|
205
|
+
updateToolCall,
|
|
206
|
+
setResponderWordCount,
|
|
207
|
+
scheduleFn,
|
|
208
|
+
setStreamState,
|
|
209
|
+
);
|
|
210
|
+
logger.debug(
|
|
211
|
+
'onToolsComplete: continueStreamingAfterTools finished',
|
|
212
|
+
'hasMoreTools:',
|
|
213
|
+
hasMoreTools,
|
|
214
|
+
);
|
|
215
|
+
} finally {
|
|
216
|
+
continuationInProgressRef.current = false;
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
logger.debug(
|
|
220
|
+
'onToolsComplete: signal aborted or no scheduler, setting idle',
|
|
221
|
+
);
|
|
222
|
+
setStreamState('idle');
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
[
|
|
226
|
+
session,
|
|
227
|
+
appendMessage,
|
|
228
|
+
appendToMessage,
|
|
229
|
+
appendToolCall,
|
|
230
|
+
updateToolCall,
|
|
231
|
+
setResponderWordCount,
|
|
232
|
+
setStreamState,
|
|
233
|
+
],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const onToolCallsUpdate = useCallback(
|
|
237
|
+
(tools: TrackedToolCall[]) => {
|
|
238
|
+
for (const tool of tools) {
|
|
239
|
+
// Queue approval for tools awaiting approval
|
|
240
|
+
if (tool.status === 'awaiting_approval') {
|
|
241
|
+
const waitingTool = tool as WaitingToolCall;
|
|
242
|
+
queueApprovalFromSchedulerRef.current(
|
|
243
|
+
tool.request.callId,
|
|
244
|
+
tool.request.name,
|
|
245
|
+
waitingTool.confirmationDetails,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
// Update UI state for tool status changes
|
|
249
|
+
switch (tool.status) {
|
|
250
|
+
case 'validating':
|
|
251
|
+
case 'scheduled':
|
|
252
|
+
case 'executing':
|
|
253
|
+
updateToolCall(tool.request.callId, { status: 'executing' });
|
|
254
|
+
break;
|
|
255
|
+
case 'success': {
|
|
256
|
+
const completed = tool as CompletedToolCall;
|
|
257
|
+
const update: { status: 'complete'; output?: string } = {
|
|
258
|
+
status: 'complete',
|
|
259
|
+
};
|
|
260
|
+
if (completed.response.resultDisplay != null) {
|
|
261
|
+
update.output =
|
|
262
|
+
typeof completed.response.resultDisplay === 'string'
|
|
263
|
+
? completed.response.resultDisplay
|
|
264
|
+
: JSON.stringify(completed.response.resultDisplay);
|
|
265
|
+
}
|
|
266
|
+
updateToolCall(tool.request.callId, update);
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
case 'error': {
|
|
270
|
+
const errorTool = tool as CompletedToolCall;
|
|
271
|
+
const errorUpdate: { status: 'error'; errorMessage?: string } = {
|
|
272
|
+
status: 'error',
|
|
273
|
+
};
|
|
274
|
+
if (errorTool.response.error?.message !== undefined) {
|
|
275
|
+
errorUpdate.errorMessage = errorTool.response.error.message;
|
|
276
|
+
}
|
|
277
|
+
updateToolCall(tool.request.callId, errorUpdate);
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
case 'cancelled':
|
|
281
|
+
updateToolCall(tool.request.callId, { status: 'cancelled' });
|
|
282
|
+
break;
|
|
283
|
+
case 'awaiting_approval': {
|
|
284
|
+
const waitingToolForUpdate = tool as WaitingToolCall;
|
|
285
|
+
const details = waitingToolForUpdate.confirmationDetails;
|
|
286
|
+
updateToolCall(tool.request.callId, {
|
|
287
|
+
status: 'confirming',
|
|
288
|
+
confirmation: {
|
|
289
|
+
confirmationType: details.type,
|
|
290
|
+
question: getQuestionForConfirmation(details),
|
|
291
|
+
preview: getPreviewForConfirmation(details),
|
|
292
|
+
canAllowAlways: true,
|
|
293
|
+
coreDetails: details,
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
[updateToolCall],
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const { schedule, cancelAll, respondToConfirmation } = useToolScheduler(
|
|
305
|
+
session?.config ?? null,
|
|
306
|
+
onToolsComplete,
|
|
307
|
+
onToolCallsUpdate,
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// Now set up useToolApproval with the respondToConfirmation from useToolScheduler
|
|
311
|
+
const {
|
|
312
|
+
pendingApproval,
|
|
313
|
+
queueApprovalFromScheduler,
|
|
314
|
+
handleDecision,
|
|
315
|
+
clearApproval,
|
|
316
|
+
} = useToolApproval(respondToConfirmation);
|
|
317
|
+
|
|
318
|
+
// Keep the ref in sync with the actual function
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
queueApprovalFromSchedulerRef.current = queueApprovalFromScheduler;
|
|
321
|
+
}, [queueApprovalFromScheduler]);
|
|
322
|
+
|
|
323
|
+
// Ref to track current pendingApproval to avoid stale closures in keyboard handlers
|
|
324
|
+
const pendingApprovalRef = useRef(pendingApproval);
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
pendingApprovalRef.current = pendingApproval;
|
|
327
|
+
}, [pendingApproval]);
|
|
328
|
+
|
|
329
|
+
// Keep scheduleRef in sync
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
scheduleRef.current = schedule;
|
|
332
|
+
}, [schedule]);
|
|
333
|
+
|
|
334
|
+
const { mountedRef, abortRef, cancelStreaming, startStreamingResponder } =
|
|
335
|
+
useStreamingLifecycle(
|
|
336
|
+
appendMessage,
|
|
337
|
+
appendToMessage,
|
|
338
|
+
appendToolCall,
|
|
339
|
+
updateToolCall,
|
|
340
|
+
setResponderWordCount,
|
|
341
|
+
setStreamState,
|
|
342
|
+
schedule,
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
// Sync abortRef to the container so onToolsComplete can access it
|
|
346
|
+
useEffect(() => {
|
|
347
|
+
abortRefContainer.current = abortRef;
|
|
348
|
+
}, [abortRef]);
|
|
349
|
+
|
|
350
|
+
useFocusAndMount(textareaRef, mountedRef);
|
|
351
|
+
|
|
352
|
+
const focusInput = useCallback(() => {
|
|
353
|
+
textareaRef.current?.focus();
|
|
354
|
+
}, []);
|
|
355
|
+
const handleThemeSelect = useCallback(
|
|
356
|
+
(t: ThemeDefinition) => {
|
|
357
|
+
setThemeBySlug(t.slug);
|
|
358
|
+
},
|
|
359
|
+
[setThemeBySlug],
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const {
|
|
363
|
+
fetchModelItems,
|
|
364
|
+
fetchProviderItems,
|
|
365
|
+
applyTheme,
|
|
366
|
+
handleConfigCommand,
|
|
367
|
+
} = useAppCommands({
|
|
368
|
+
sessionConfig,
|
|
369
|
+
setSessionConfig,
|
|
370
|
+
themes,
|
|
371
|
+
setThemeBySlug,
|
|
372
|
+
appendMessage,
|
|
373
|
+
createSession,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
useSuggestionSetup(themes);
|
|
377
|
+
|
|
378
|
+
const { autoFollow, setAutoFollow, handleContentChange, handleMouseScroll } =
|
|
379
|
+
useScrollManagement(scrollRef);
|
|
380
|
+
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
handleContentChange();
|
|
383
|
+
}, [handleContentChange, entries.length]);
|
|
384
|
+
|
|
385
|
+
const handleCommand = useCallback(
|
|
386
|
+
async (command: string) => {
|
|
387
|
+
const configResult = await handleConfigCommand(command);
|
|
388
|
+
if (configResult.handled) return true;
|
|
389
|
+
if (command.startsWith('/theme')) {
|
|
390
|
+
const parts = command.trim().split(/\s+/);
|
|
391
|
+
if (parts.length === 1) return triggerCommand('/theme');
|
|
392
|
+
applyTheme(parts.slice(1).join(' '));
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
if (command === '/clear') {
|
|
396
|
+
// Reset the model's conversation history if session exists
|
|
397
|
+
if (session) {
|
|
398
|
+
try {
|
|
399
|
+
await session.getClient().resetChat();
|
|
400
|
+
} catch (error) {
|
|
401
|
+
logger.error('Failed to reset chat:', error);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Clear the UI entries and reset counts
|
|
405
|
+
clearEntries();
|
|
406
|
+
// Clear the terminal screen
|
|
407
|
+
console.clear();
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
return triggerCommand(command);
|
|
411
|
+
},
|
|
412
|
+
[applyTheme, handleConfigCommand, triggerCommand, session, clearEntries],
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const {
|
|
416
|
+
inputLineCount,
|
|
417
|
+
enforceInputLineBounds,
|
|
418
|
+
handleSubmit,
|
|
419
|
+
handleTabComplete,
|
|
420
|
+
} = useInputManager(
|
|
421
|
+
textareaRef,
|
|
422
|
+
appendMessage,
|
|
423
|
+
setPromptCount,
|
|
424
|
+
setAutoFollow,
|
|
425
|
+
(prompt) => {
|
|
426
|
+
if (!session) {
|
|
427
|
+
appendMessage(
|
|
428
|
+
'system',
|
|
429
|
+
'No active session. Load a profile first with /profile load <name>',
|
|
430
|
+
);
|
|
431
|
+
return Promise.resolve();
|
|
432
|
+
}
|
|
433
|
+
// Reset continuation guard when starting a new prompt
|
|
434
|
+
continuationInProgressRef.current = false;
|
|
435
|
+
// Note: AbortController is created by useStreamingResponder internally
|
|
436
|
+
return startStreamingResponder(prompt, session);
|
|
437
|
+
},
|
|
438
|
+
refreshCompletion,
|
|
439
|
+
clearCompletion,
|
|
440
|
+
applySelection,
|
|
441
|
+
handleCommand,
|
|
442
|
+
recordHistory,
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
const statusLabel = useMemo(
|
|
446
|
+
() => buildStatusLabel(streamState, autoFollow),
|
|
447
|
+
[autoFollow, streamState],
|
|
448
|
+
);
|
|
449
|
+
const handleMouseUp = useSelectionClipboard(renderer);
|
|
450
|
+
const handleSubmitWrapped = useCallback(() => {
|
|
451
|
+
void handleSubmit();
|
|
452
|
+
}, [handleSubmit]);
|
|
453
|
+
|
|
454
|
+
const handleCancelAll = useCallback(() => {
|
|
455
|
+
logger.debug('handleCancelAll called');
|
|
456
|
+
cancelStreaming();
|
|
457
|
+
cancelAll();
|
|
458
|
+
// cancelStreaming already aborts abortRef and sets idle
|
|
459
|
+
}, [cancelStreaming, cancelAll]);
|
|
460
|
+
|
|
461
|
+
// Approval keyboard handling - select option or cancel
|
|
462
|
+
// Uses ref to avoid stale closure issues with pendingApproval
|
|
463
|
+
const handleApprovalSelectKeyboard = useCallback(
|
|
464
|
+
(outcome: ToolApprovalOutcome) => {
|
|
465
|
+
const current = pendingApprovalRef.current;
|
|
466
|
+
logger.debug(
|
|
467
|
+
'handleApprovalSelectKeyboard called',
|
|
468
|
+
'outcome:',
|
|
469
|
+
outcome,
|
|
470
|
+
'callId:',
|
|
471
|
+
current?.callId,
|
|
472
|
+
);
|
|
473
|
+
if (current) {
|
|
474
|
+
handleDecision(current.callId, outcome);
|
|
475
|
+
// If user cancelled, also cancel all tools and streaming to break the loop
|
|
476
|
+
if (outcome === 'cancel') {
|
|
477
|
+
handleCancelAll();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
[handleDecision, handleCancelAll],
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
// Callback for ChatLayout inline approval UI
|
|
485
|
+
const handleApprovalSelectFromUI = useCallback(
|
|
486
|
+
(callId: string, outcome: ToolApprovalOutcome) => {
|
|
487
|
+
handleDecision(callId, outcome);
|
|
488
|
+
// If user cancelled, also cancel all tools and streaming to break the loop
|
|
489
|
+
if (outcome === 'cancel') {
|
|
490
|
+
handleCancelAll();
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
[handleDecision, handleCancelAll],
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
const handleApprovalCancel = useCallback(() => {
|
|
497
|
+
logger.debug('handleApprovalCancel called');
|
|
498
|
+
clearApproval();
|
|
499
|
+
// Also cancel all tools and streaming when Esc is pressed during approval
|
|
500
|
+
handleCancelAll();
|
|
501
|
+
}, [clearApproval, handleCancelAll]);
|
|
502
|
+
|
|
503
|
+
// Wire up keyboard navigation for inline approval
|
|
504
|
+
// This must be called before useEnterSubmit to intercept keys when approval is active
|
|
505
|
+
const { selectedIndex: approvalSelectedIndex } = useApprovalKeyboard({
|
|
506
|
+
isActive: pendingApproval !== null,
|
|
507
|
+
canAllowAlways: true, // We allow "always" for all tools currently
|
|
508
|
+
onSelect: handleApprovalSelectKeyboard,
|
|
509
|
+
onCancel: handleApprovalCancel,
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// Disable normal enter submit when approval is active
|
|
513
|
+
useEnterSubmit(
|
|
514
|
+
() => void handleSubmit(),
|
|
515
|
+
dialog.isOpen || pendingApproval !== null,
|
|
516
|
+
);
|
|
517
|
+
useSuggestionKeybindings(
|
|
518
|
+
dialog.isOpen || pendingApproval !== null ? 0 : suggestions.length,
|
|
519
|
+
moveSelection,
|
|
520
|
+
handleTabComplete,
|
|
521
|
+
handleCancelAll,
|
|
522
|
+
() => {
|
|
523
|
+
textareaRef.current?.clear();
|
|
524
|
+
enforceInputLineBounds();
|
|
525
|
+
return Promise.resolve();
|
|
526
|
+
},
|
|
527
|
+
() => streamState === 'busy',
|
|
528
|
+
() => (textareaRef.current?.plainText ?? '').trim() === '',
|
|
529
|
+
);
|
|
530
|
+
useHistoryNavigation(
|
|
531
|
+
dialog.isOpen || pendingApproval !== null,
|
|
532
|
+
suggestions.length,
|
|
533
|
+
handleHistoryKey,
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
// Build inline approval state for ChatLayout
|
|
537
|
+
const pendingApprovalState: PendingApprovalState | undefined = pendingApproval
|
|
538
|
+
? { callId: pendingApproval.callId, selectedIndex: approvalSelectedIndex }
|
|
539
|
+
: undefined;
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
<>
|
|
543
|
+
<CommandComponents
|
|
544
|
+
fetchModelItems={fetchModelItems}
|
|
545
|
+
fetchProviderItems={fetchProviderItems}
|
|
546
|
+
sessionConfig={sessionConfig}
|
|
547
|
+
setSessionConfig={setSessionConfig}
|
|
548
|
+
appendMessage={appendMessage}
|
|
549
|
+
themes={themes}
|
|
550
|
+
currentTheme={theme}
|
|
551
|
+
onThemeSelect={handleThemeSelect}
|
|
552
|
+
focusInput={focusInput}
|
|
553
|
+
/>
|
|
554
|
+
<ChatLayout
|
|
555
|
+
headerText={HEADER_TEXT}
|
|
556
|
+
entries={entries}
|
|
557
|
+
scrollRef={scrollRef}
|
|
558
|
+
autoFollow={autoFollow}
|
|
559
|
+
textareaRef={textareaRef}
|
|
560
|
+
inputLineCount={inputLineCount}
|
|
561
|
+
enforceInputLineBounds={enforceInputLineBounds}
|
|
562
|
+
handleSubmit={handleSubmitWrapped}
|
|
563
|
+
statusLabel={statusLabel}
|
|
564
|
+
promptCount={promptCount}
|
|
565
|
+
responderWordCount={responderWordCount}
|
|
566
|
+
streamState={streamState}
|
|
567
|
+
onScroll={handleMouseScroll}
|
|
568
|
+
onMouseUp={handleMouseUp}
|
|
569
|
+
suggestions={suggestions}
|
|
570
|
+
selectedSuggestion={selectedIndex}
|
|
571
|
+
theme={theme}
|
|
572
|
+
inputDisabled={pendingApproval !== null}
|
|
573
|
+
{...(pendingApprovalState
|
|
574
|
+
? { pendingApproval: pendingApprovalState }
|
|
575
|
+
: {})}
|
|
576
|
+
{...(pendingApprovalState
|
|
577
|
+
? { onApprovalSelect: handleApprovalSelectFromUI }
|
|
578
|
+
: {})}
|
|
579
|
+
/>
|
|
580
|
+
</>
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function AppWithCommand(): React.ReactNode {
|
|
585
|
+
const dialog = useDialog();
|
|
586
|
+
return (
|
|
587
|
+
<Command dialogContext={dialog}>
|
|
588
|
+
<AppInner />
|
|
589
|
+
</Command>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export function App(): React.ReactNode {
|
|
594
|
+
return (
|
|
595
|
+
<Dialog>
|
|
596
|
+
<AppWithCommand />
|
|
597
|
+
</Dialog>
|
|
598
|
+
);
|
|
599
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createCliRenderer } from '@vybestack/opentui-core';
|
|
2
|
+
import { createRoot } from '@vybestack/opentui-react';
|
|
3
|
+
import { App } from './app';
|
|
4
|
+
import type { UILaunchConfig } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Start the NUI with the given configuration
|
|
8
|
+
* This is the entry point when launched from the CLI with --experimental-ui
|
|
9
|
+
*/
|
|
10
|
+
export async function startNui(config: UILaunchConfig): Promise<void> {
|
|
11
|
+
// Config initialization happens in App component
|
|
12
|
+
console.log('Starting NUI with config:', config);
|
|
13
|
+
|
|
14
|
+
const renderer = await createCliRenderer({
|
|
15
|
+
exitOnCtrlC: true,
|
|
16
|
+
useMouse: true,
|
|
17
|
+
useAlternateScreen: true,
|
|
18
|
+
useKittyKeyboard: { events: true },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
createRoot(renderer).render(<App />);
|
|
22
|
+
renderer.start();
|
|
23
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { useCommand } from '../uicontext';
|
|
9
|
+
import { AuthModal, AUTH_DEFAULTS, type AuthOption } from '../ui/modals';
|
|
10
|
+
import type { ThemeDefinition } from '../features/theme';
|
|
11
|
+
|
|
12
|
+
interface AuthCommandProps {
|
|
13
|
+
readonly appendMessage: (
|
|
14
|
+
role: 'user' | 'model' | 'system',
|
|
15
|
+
text: string,
|
|
16
|
+
) => string;
|
|
17
|
+
readonly theme: ThemeDefinition;
|
|
18
|
+
readonly focusInput: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AuthCommand({
|
|
22
|
+
appendMessage,
|
|
23
|
+
theme,
|
|
24
|
+
focusInput,
|
|
25
|
+
}: AuthCommandProps): React.ReactNode | null {
|
|
26
|
+
const { register } = useCommand();
|
|
27
|
+
const [authOptions, setAuthOptions] = useState<AuthOption[]>(AUTH_DEFAULTS);
|
|
28
|
+
const dialogClearRef = useRef<(() => void) | null>(null);
|
|
29
|
+
|
|
30
|
+
const handleSave = useCallback(
|
|
31
|
+
(next: AuthOption[]): void => {
|
|
32
|
+
setAuthOptions(next);
|
|
33
|
+
const enabled = next
|
|
34
|
+
.filter((opt) => opt.id !== 'close' && opt.enabled)
|
|
35
|
+
.map((opt) => opt.label.replace(/^\d+\.\s*/, ''));
|
|
36
|
+
appendMessage(
|
|
37
|
+
'system',
|
|
38
|
+
`Auth providers: ${enabled.join(', ') || 'none'}`,
|
|
39
|
+
);
|
|
40
|
+
},
|
|
41
|
+
[appendMessage],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const handleClose = useCallback((): void => {
|
|
45
|
+
if (dialogClearRef.current !== null) {
|
|
46
|
+
dialogClearRef.current();
|
|
47
|
+
}
|
|
48
|
+
focusInput();
|
|
49
|
+
}, [focusInput]);
|
|
50
|
+
|
|
51
|
+
const modal = useMemo(
|
|
52
|
+
() => (
|
|
53
|
+
<AuthModal
|
|
54
|
+
options={authOptions}
|
|
55
|
+
onClose={handleClose}
|
|
56
|
+
onSave={handleSave}
|
|
57
|
+
theme={theme}
|
|
58
|
+
/>
|
|
59
|
+
),
|
|
60
|
+
[authOptions, handleClose, handleSave, theme],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const cleanup = register([
|
|
65
|
+
{
|
|
66
|
+
name: '/auth',
|
|
67
|
+
title: 'OAuth Authentication',
|
|
68
|
+
category: 'authentication',
|
|
69
|
+
onExecute: (dialog) => {
|
|
70
|
+
dialogClearRef.current = dialog.clear;
|
|
71
|
+
dialog.replace(modal);
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
return cleanup;
|
|
77
|
+
}, [register, modal]);
|
|
78
|
+
|
|
79
|
+
return null;
|
|
80
|
+
}
|