@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,129 @@
1
+ import { useCallback } from 'react';
2
+ import type { SessionConfig } from '../features/config';
3
+ import type { ConfigSessionOptions } from '../features/config/configSession';
4
+ import { listModels, listProviders } from '../features/config';
5
+ import {
6
+ applyProfileWithSession,
7
+ validateSessionConfig,
8
+ } from '../features/config';
9
+ import { findTheme, type ThemeDefinition } from '../features/theme';
10
+
11
+ interface ItemFetchResult {
12
+ items: { id: string; label: string }[];
13
+ messages?: string[];
14
+ }
15
+
16
+ interface ConfigCommandResult {
17
+ handled: boolean;
18
+ nextConfig: SessionConfig;
19
+ messages: string[];
20
+ }
21
+
22
+ interface UseAppCommandsProps {
23
+ sessionConfig: SessionConfig;
24
+ setSessionConfig: (config: SessionConfig) => void;
25
+ themes: ThemeDefinition[];
26
+ setThemeBySlug: (slug: string) => void;
27
+ appendMessage: (role: 'user' | 'model' | 'system', text: string) => string;
28
+ createSession: (options: ConfigSessionOptions) => Promise<void>;
29
+ }
30
+
31
+ interface UseAppCommandsResult {
32
+ fetchModelItems: () => Promise<ItemFetchResult>;
33
+ fetchProviderItems: () => Promise<ItemFetchResult>;
34
+ applyTheme: (key: string) => void;
35
+ handleConfigCommand: (command: string) => Promise<ConfigCommandResult>;
36
+ }
37
+
38
+ export function useAppCommands({
39
+ sessionConfig,
40
+ setSessionConfig,
41
+ themes,
42
+ setThemeBySlug,
43
+ appendMessage,
44
+ createSession,
45
+ }: UseAppCommandsProps): UseAppCommandsResult {
46
+ const fetchModelItems = useCallback(async (): Promise<ItemFetchResult> => {
47
+ const missing = validateSessionConfig(sessionConfig, {
48
+ requireModel: false,
49
+ });
50
+ if (missing.length > 0) {
51
+ return { items: [], messages: missing };
52
+ }
53
+ try {
54
+ const models = await listModels(sessionConfig);
55
+ const items = models.map((model) => ({
56
+ id: model.id,
57
+ label: model.name || model.id,
58
+ }));
59
+ return { items };
60
+ } catch (error) {
61
+ const message = error instanceof Error ? error.message : String(error);
62
+ return { items: [], messages: [`Failed to load models: ${message}`] };
63
+ }
64
+ }, [sessionConfig]);
65
+
66
+ const fetchProviderItems = useCallback(async (): Promise<ItemFetchResult> => {
67
+ try {
68
+ const providers = await Promise.resolve(listProviders());
69
+ const items = providers.map((p) => ({ id: p.id, label: p.label }));
70
+ return { items };
71
+ } catch (error) {
72
+ const message = error instanceof Error ? error.message : String(error);
73
+ return { items: [], messages: [`Failed to load providers: ${message}`] };
74
+ }
75
+ }, []);
76
+
77
+ const applyTheme = useCallback(
78
+ (key: string) => {
79
+ const match = findTheme(themes, key);
80
+ if (!match) {
81
+ appendMessage('system', `Theme not found: ${key}`);
82
+ return;
83
+ }
84
+ setThemeBySlug(match.slug);
85
+ appendMessage('system', `Theme set to ${match.name}`);
86
+ },
87
+ [appendMessage, setThemeBySlug, themes],
88
+ );
89
+
90
+ const handleConfigCommand = useCallback(
91
+ async (command: string): Promise<ConfigCommandResult> => {
92
+ const configResult = await applyProfileWithSession(
93
+ command,
94
+ sessionConfig,
95
+ {
96
+ workingDir: process.cwd(),
97
+ },
98
+ );
99
+ if (configResult.handled) {
100
+ setSessionConfig(configResult.nextConfig);
101
+ for (const msg of configResult.messages) {
102
+ appendMessage('system', msg);
103
+ }
104
+
105
+ // If profile was loaded successfully, create the session
106
+ if (configResult.sessionOptions) {
107
+ try {
108
+ appendMessage('system', 'Initializing session...');
109
+ await createSession(configResult.sessionOptions);
110
+ appendMessage('system', 'Session ready. You can now chat.');
111
+ } catch (error) {
112
+ const message =
113
+ error instanceof Error ? error.message : String(error);
114
+ appendMessage('system', `Failed to initialize session: ${message}`);
115
+ }
116
+ }
117
+ }
118
+ return configResult;
119
+ },
120
+ [appendMessage, sessionConfig, setSessionConfig, createSession],
121
+ );
122
+
123
+ return {
124
+ fetchModelItems,
125
+ fetchProviderItems,
126
+ applyTheme,
127
+ handleConfigCommand,
128
+ };
129
+ }
@@ -0,0 +1,156 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useKeyboard } from '@vybestack/opentui-react';
3
+ import type { ToolApprovalOutcome } from '../ui/components/ChatLayout';
4
+ import { getLogger } from '../lib/logger';
5
+
6
+ const logger = getLogger('nui:approval-keyboard');
7
+
8
+ /** Approval options in order */
9
+ const APPROVAL_OPTIONS: ToolApprovalOutcome[] = [
10
+ 'allow_once',
11
+ 'allow_always',
12
+ 'cancel',
13
+ ];
14
+
15
+ interface UseApprovalKeyboardOptions {
16
+ /** Whether approval is currently active */
17
+ isActive: boolean;
18
+ /** Whether "allow always" option is available */
19
+ canAllowAlways: boolean;
20
+ /** Callback when user selects an option */
21
+ onSelect: (outcome: ToolApprovalOutcome) => void;
22
+ /** Callback when user cancels (Esc) */
23
+ onCancel: () => void;
24
+ }
25
+
26
+ interface UseApprovalKeyboardResult {
27
+ /** Currently selected index */
28
+ selectedIndex: number;
29
+ /** Total number of options */
30
+ optionCount: number;
31
+ }
32
+
33
+ /**
34
+ * Hook to handle keyboard navigation for inline tool approval.
35
+ * Captures arrow keys and enter when approval is active.
36
+ */
37
+ export function useApprovalKeyboard(
38
+ options: UseApprovalKeyboardOptions,
39
+ ): UseApprovalKeyboardResult {
40
+ const { isActive, canAllowAlways, onSelect, onCancel } = options;
41
+ const [selectedIndex, setSelectedIndex] = useState(0);
42
+
43
+ // Use refs to avoid stale closures in keyboard handler
44
+ const isActiveRef = useRef(isActive);
45
+ const canAllowAlwaysRef = useRef(canAllowAlways);
46
+ const onSelectRef = useRef(onSelect);
47
+ const onCancelRef = useRef(onCancel);
48
+ const selectedIndexRef = useRef(selectedIndex);
49
+
50
+ // Keep refs in sync
51
+ useEffect(() => {
52
+ isActiveRef.current = isActive;
53
+ }, [isActive]);
54
+ useEffect(() => {
55
+ canAllowAlwaysRef.current = canAllowAlways;
56
+ }, [canAllowAlways]);
57
+ useEffect(() => {
58
+ onSelectRef.current = onSelect;
59
+ }, [onSelect]);
60
+ useEffect(() => {
61
+ onCancelRef.current = onCancel;
62
+ }, [onCancel]);
63
+ useEffect(() => {
64
+ selectedIndexRef.current = selectedIndex;
65
+ }, [selectedIndex]);
66
+
67
+ // Get available options based on canAllowAlways
68
+ const availableOptions = canAllowAlways
69
+ ? APPROVAL_OPTIONS
70
+ : APPROVAL_OPTIONS.filter((o) => o !== 'allow_always');
71
+
72
+ const optionCount = availableOptions.length;
73
+
74
+ // Reset selection when approval becomes active
75
+ useEffect(() => {
76
+ if (isActive) {
77
+ setSelectedIndex(0);
78
+ }
79
+ }, [isActive]);
80
+
81
+ // Use useKeyboard hook to intercept keys when approval is active
82
+ useKeyboard((key) => {
83
+ logger.debug('key received', key.name, 'isActive:', isActiveRef.current);
84
+ if (!isActiveRef.current) return;
85
+
86
+ const currentCanAllowAlways = canAllowAlwaysRef.current;
87
+ const currentOptions = currentCanAllowAlways
88
+ ? APPROVAL_OPTIONS
89
+ : APPROVAL_OPTIONS.filter((o) => o !== 'allow_always');
90
+ const currentOptionCount = currentOptions.length;
91
+
92
+ let handled = false;
93
+
94
+ switch (key.name) {
95
+ case 'up':
96
+ setSelectedIndex((prev) =>
97
+ prev > 0 ? prev - 1 : currentOptionCount - 1,
98
+ );
99
+ handled = true;
100
+ break;
101
+ case 'down':
102
+ setSelectedIndex((prev) =>
103
+ prev < currentOptionCount - 1 ? prev + 1 : 0,
104
+ );
105
+ handled = true;
106
+ break;
107
+ case 'return':
108
+ case 'kpenter': {
109
+ const outcome = currentOptions[selectedIndexRef.current];
110
+ logger.debug(
111
+ 'Enter pressed',
112
+ 'selectedIndex:',
113
+ selectedIndexRef.current,
114
+ 'outcome:',
115
+ outcome,
116
+ );
117
+ onSelectRef.current(outcome);
118
+ handled = true;
119
+ break;
120
+ }
121
+ case 'escape':
122
+ logger.debug('Escape pressed, calling onCancel');
123
+ onCancelRef.current();
124
+ handled = true;
125
+ break;
126
+ case '1':
127
+ logger.debug('1 pressed, selecting allow_once');
128
+ onSelectRef.current('allow_once');
129
+ handled = true;
130
+ break;
131
+ case '2':
132
+ if (currentCanAllowAlways) {
133
+ onSelectRef.current('allow_always');
134
+ } else {
135
+ onSelectRef.current('cancel');
136
+ }
137
+ handled = true;
138
+ break;
139
+ case '3':
140
+ if (currentCanAllowAlways) {
141
+ onSelectRef.current('cancel');
142
+ handled = true;
143
+ }
144
+ break;
145
+ }
146
+
147
+ if (handled) {
148
+ key.preventDefault();
149
+ }
150
+ });
151
+
152
+ return {
153
+ selectedIndex,
154
+ optionCount,
155
+ };
156
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { renderHook, act } from '@testing-library/react';
3
+ import { useChatStore } from './useChatStore';
4
+
5
+ describe('useChatStore message handling', () => {
6
+ let idCounter = 0;
7
+ const makeId = () => `test-${idCounter++}`;
8
+
9
+ it('should append a system message', () => {
10
+ const { result } = renderHook(() => useChatStore(makeId));
11
+
12
+ act(() => {
13
+ result.current.appendMessage('system', 'System notification');
14
+ });
15
+
16
+ expect(result.current.entries).toHaveLength(1);
17
+ expect(result.current.entries[0]).toMatchObject({
18
+ kind: 'message',
19
+ role: 'system',
20
+ text: 'System notification',
21
+ });
22
+ });
23
+
24
+ it('should append a model message', () => {
25
+ const { result } = renderHook(() => useChatStore(makeId));
26
+
27
+ act(() => {
28
+ result.current.appendMessage('model', 'Model response');
29
+ });
30
+
31
+ expect(result.current.entries).toHaveLength(1);
32
+ expect(result.current.entries[0]).toMatchObject({
33
+ kind: 'message',
34
+ role: 'model',
35
+ text: 'Model response',
36
+ });
37
+ });
38
+
39
+ it('should store messages with correct role', () => {
40
+ const { result } = renderHook(() => useChatStore(makeId));
41
+
42
+ act(() => {
43
+ result.current.appendMessage('user', 'User input');
44
+ result.current.appendMessage('model', 'Model response');
45
+ result.current.appendMessage('thinking', 'Model thinking');
46
+ result.current.appendMessage('system', 'System message');
47
+ });
48
+
49
+ expect(result.current.entries).toHaveLength(4);
50
+ const messages = result.current.entries.filter((e) => e.kind === 'message');
51
+ expect(messages[0].role).toBe('user');
52
+ expect(messages[1].role).toBe('model');
53
+ expect(messages[2].role).toBe('thinking');
54
+ expect(messages[3].role).toBe('system');
55
+ });
56
+
57
+ it('should append text to an existing message', () => {
58
+ const { result } = renderHook(() => useChatStore(makeId));
59
+
60
+ let messageId: string;
61
+ act(() => {
62
+ messageId = result.current.appendMessage('model', 'First line');
63
+ });
64
+
65
+ act(() => {
66
+ result.current.appendToMessage(messageId!, '\nSecond line');
67
+ });
68
+
69
+ expect(result.current.entries).toHaveLength(1);
70
+ const message = result.current.entries[0];
71
+ expect(message.kind).toBe('message');
72
+ expect((message as { text: string }).text).toBe('First line\nSecond line');
73
+ });
74
+
75
+ it('should return the message id from appendMessage', () => {
76
+ const { result } = renderHook(() => useChatStore(makeId));
77
+
78
+ let messageId: string;
79
+ act(() => {
80
+ messageId = result.current.appendMessage('user', 'Test message');
81
+ });
82
+
83
+ expect(messageId!).toBeDefined();
84
+ expect(typeof messageId!).toBe('string');
85
+ expect(result.current.entries[0].id).toBe(messageId!);
86
+ });
87
+
88
+ it('should clear all entries and reset counts', () => {
89
+ const { result } = renderHook(() => useChatStore(makeId));
90
+
91
+ // Add some entries and update counts
92
+ act(() => {
93
+ result.current.appendMessage('user', 'First message');
94
+ result.current.appendMessage('model', 'Response');
95
+ result.current.setPromptCount(5);
96
+ result.current.setResponderWordCount(100);
97
+ });
98
+
99
+ expect(result.current.entries).toHaveLength(2);
100
+ expect(result.current.promptCount).toBe(5);
101
+ expect(result.current.responderWordCount).toBe(100);
102
+
103
+ // Clear everything
104
+ act(() => {
105
+ result.current.clearEntries();
106
+ });
107
+
108
+ expect(result.current.entries).toHaveLength(0);
109
+ expect(result.current.promptCount).toBe(0);
110
+ expect(result.current.responderWordCount).toBe(0);
111
+ });
112
+ });
@@ -0,0 +1,252 @@
1
+ import type { Dispatch, SetStateAction } from 'react';
2
+ import { useCallback, useState } from 'react';
3
+ import type { MessageRole } from '../ui/components/messages';
4
+ import type { ToolStatus, ToolConfirmationType } from '../types/events';
5
+ import type { ToolCallConfirmationDetails } from '@vybestack/llxprt-code-core';
6
+
7
+ type Role = MessageRole;
8
+ /**
9
+ * Stream state represents whether the system is waiting for user input or busy with LLM operations.
10
+ * - "idle": Ready for user input
11
+ * - "busy": Processing (streaming from model, executing tools, or waiting for tool responses)
12
+ */
13
+ type StreamState = 'idle' | 'busy';
14
+
15
+ interface ChatMessage {
16
+ id: string;
17
+ kind: 'message';
18
+ role: Role;
19
+ text: string;
20
+ }
21
+
22
+ interface ToolBlockLegacy {
23
+ id: string;
24
+ kind: 'tool';
25
+ lines: string[];
26
+ isBatch: boolean;
27
+ scrollable?: boolean;
28
+ maxHeight?: number;
29
+ streaming?: boolean;
30
+ }
31
+
32
+ interface ToolCall {
33
+ id: string;
34
+ kind: 'toolcall';
35
+ /** The tool call ID from the backend */
36
+ callId: string;
37
+ name: string;
38
+ params: Record<string, unknown>;
39
+ status: ToolStatus;
40
+ /** Tool output after execution */
41
+ output?: string;
42
+ /** Error message if failed */
43
+ errorMessage?: string;
44
+ /** Confirmation details if awaiting approval */
45
+ confirmation?: {
46
+ confirmationType: ToolConfirmationType;
47
+ question: string;
48
+ preview: string;
49
+ canAllowAlways: boolean;
50
+ /** Full confirmation details from CoreToolScheduler (includes diff for edits) */
51
+ coreDetails?: ToolCallConfirmationDetails;
52
+ };
53
+ }
54
+
55
+ type ToolBlock = ToolBlockLegacy | ToolCall;
56
+
57
+ type ChatEntry = ChatMessage | ToolBlock;
58
+
59
+ type StateSetter<T> = Dispatch<SetStateAction<T>>;
60
+
61
+ export type {
62
+ Role,
63
+ StreamState,
64
+ ChatMessage,
65
+ ToolBlock,
66
+ ToolBlockLegacy,
67
+ ToolCall,
68
+ ChatEntry,
69
+ StateSetter,
70
+ };
71
+
72
+ export interface UseChatStoreReturn {
73
+ entries: ChatEntry[];
74
+ appendMessage: (role: Role, text: string) => string;
75
+ appendToMessage: (id: string, text: string) => void;
76
+ appendToolBlock: (tool: {
77
+ lines: string[];
78
+ isBatch: boolean;
79
+ scrollable?: boolean;
80
+ maxHeight?: number;
81
+ streaming?: boolean;
82
+ }) => string;
83
+ appendToolCall: (
84
+ callId: string,
85
+ name: string,
86
+ params: Record<string, unknown>,
87
+ ) => string;
88
+ updateToolCall: (
89
+ callId: string,
90
+ update: Partial<Omit<ToolCall, 'id' | 'kind' | 'callId'>>,
91
+ ) => void;
92
+ findToolCallByCallId: (callId: string) => ToolCall | undefined;
93
+ clearEntries: () => void;
94
+ promptCount: number;
95
+ setPromptCount: StateSetter<number>;
96
+ responderWordCount: number;
97
+ setResponderWordCount: StateSetter<number>;
98
+ streamState: StreamState;
99
+ setStreamState: StateSetter<StreamState>;
100
+ updateToolBlock: (
101
+ id: string,
102
+ mutate: (block: ToolBlock) => ToolBlock,
103
+ ) => void;
104
+ }
105
+
106
+ export function useChatStore(makeId: () => string): UseChatStoreReturn {
107
+ const [entries, setEntries] = useState<ChatEntry[]>([]);
108
+ const [promptCount, setPromptCount] = useState(0);
109
+ const [responderWordCount, setResponderWordCount] = useState(0);
110
+ const [streamState, setStreamState] = useState<StreamState>('idle');
111
+
112
+ const appendMessage = useCallback(
113
+ (role: Role, text: string): string => {
114
+ const id = makeId();
115
+ setEntries((prev) => [
116
+ ...prev,
117
+ {
118
+ id,
119
+ kind: 'message',
120
+ role,
121
+ text,
122
+ },
123
+ ]);
124
+ return id;
125
+ },
126
+ [makeId],
127
+ );
128
+
129
+ const appendToMessage = useCallback((id: string, text: string): void => {
130
+ setEntries((prev) =>
131
+ prev.map((entry) => {
132
+ if (entry.kind !== 'message' || entry.id !== id) {
133
+ return entry;
134
+ }
135
+ return { ...entry, text: entry.text + text };
136
+ }),
137
+ );
138
+ }, []);
139
+
140
+ const appendToolBlock = useCallback(
141
+ (tool: {
142
+ lines: string[];
143
+ isBatch: boolean;
144
+ scrollable?: boolean;
145
+ maxHeight?: number;
146
+ streaming?: boolean;
147
+ }) => {
148
+ const id = makeId();
149
+ setEntries((prev) => [
150
+ ...prev,
151
+ {
152
+ id,
153
+ kind: 'tool',
154
+ lines: tool.lines,
155
+ isBatch: tool.isBatch,
156
+ scrollable: tool.scrollable,
157
+ maxHeight: tool.maxHeight,
158
+ streaming: tool.streaming,
159
+ },
160
+ ]);
161
+ return id;
162
+ },
163
+ [makeId],
164
+ );
165
+
166
+ const appendToolCall = useCallback(
167
+ (callId: string, name: string, params: Record<string, unknown>): string => {
168
+ const id = makeId();
169
+ setEntries((prev) => [
170
+ ...prev,
171
+ {
172
+ id,
173
+ kind: 'toolcall',
174
+ callId,
175
+ name,
176
+ params,
177
+ status: 'pending' as const,
178
+ },
179
+ ]);
180
+ return id;
181
+ },
182
+ [makeId],
183
+ );
184
+
185
+ const updateToolCall = useCallback(
186
+ (
187
+ callId: string,
188
+ update: Partial<Omit<ToolCall, 'id' | 'kind' | 'callId'>>,
189
+ ) => {
190
+ setEntries((prev) =>
191
+ prev.map((item) => {
192
+ if (item.kind !== 'toolcall' || item.callId !== callId) {
193
+ return item;
194
+ }
195
+ return { ...item, ...update };
196
+ }),
197
+ );
198
+ },
199
+ [],
200
+ );
201
+
202
+ const findToolCallByCallId = useCallback(
203
+ (callId: string): ToolCall | undefined => {
204
+ return entries.find(
205
+ (entry): entry is ToolCall =>
206
+ entry.kind === 'toolcall' && entry.callId === callId,
207
+ );
208
+ },
209
+ [entries],
210
+ );
211
+
212
+ const updateToolBlock = useCallback(
213
+ (id: string, mutate: (block: ToolBlock) => ToolBlock) => {
214
+ setEntries((prev) =>
215
+ prev.map((item) => {
216
+ if (
217
+ (item.kind !== 'tool' && item.kind !== 'toolcall') ||
218
+ item.id !== id
219
+ ) {
220
+ return item;
221
+ }
222
+ return mutate(item);
223
+ }),
224
+ );
225
+ },
226
+ [],
227
+ );
228
+
229
+ const clearEntries = useCallback(() => {
230
+ setEntries([]);
231
+ setPromptCount(0);
232
+ setResponderWordCount(0);
233
+ }, []);
234
+
235
+ return {
236
+ entries,
237
+ appendMessage,
238
+ appendToMessage,
239
+ appendToolBlock,
240
+ appendToolCall,
241
+ updateToolCall,
242
+ findToolCallByCallId,
243
+ clearEntries,
244
+ promptCount,
245
+ setPromptCount,
246
+ responderWordCount,
247
+ setResponderWordCount,
248
+ streamState,
249
+ setStreamState,
250
+ updateToolBlock,
251
+ };
252
+ }