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