@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,102 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
8
+ import { useCommand } from '../uicontext';
9
+ import { SearchSelectModal } from '../ui/modals';
10
+ import type { SearchItem } from '../ui/modals/types';
11
+ import type { SessionConfig } from '../features/config';
12
+ import type { ThemeDefinition } from '../features/theme';
13
+
14
+ interface ModelCommandProps {
15
+ readonly fetchModelItems: () => Promise<{
16
+ items: SearchItem[];
17
+ messages?: string[];
18
+ }>;
19
+ readonly sessionConfig: SessionConfig;
20
+ readonly setSessionConfig: (config: SessionConfig) => void;
21
+ readonly appendMessage: (
22
+ role: 'user' | 'model' | 'system',
23
+ text: string,
24
+ ) => string;
25
+ readonly theme: ThemeDefinition;
26
+ readonly focusInput: () => void;
27
+ }
28
+
29
+ export function ModelCommand({
30
+ fetchModelItems,
31
+ sessionConfig,
32
+ setSessionConfig,
33
+ appendMessage,
34
+ theme,
35
+ focusInput,
36
+ }: ModelCommandProps): React.ReactNode | null {
37
+ const { register } = useCommand();
38
+ const dialogClearRef = useRef<(() => void) | null>(null);
39
+ const [modalItems, setModalItems] = useState<SearchItem[]>([]);
40
+
41
+ const handleClose = useCallback((): void => {
42
+ if (dialogClearRef.current !== null) {
43
+ dialogClearRef.current();
44
+ }
45
+ focusInput();
46
+ }, [focusInput]);
47
+
48
+ const handleSelect = useCallback(
49
+ (item: SearchItem): void => {
50
+ setSessionConfig({ ...sessionConfig, model: item.id });
51
+ appendMessage('system', `Selected model: ${item.label}`);
52
+ if (dialogClearRef.current !== null) {
53
+ dialogClearRef.current();
54
+ }
55
+ focusInput();
56
+ },
57
+ [sessionConfig, setSessionConfig, appendMessage, focusInput],
58
+ );
59
+
60
+ const modal = useMemo(
61
+ () => (
62
+ <SearchSelectModal
63
+ title="Search Models"
64
+ noun="models"
65
+ items={modalItems}
66
+ alphabetical
67
+ footerHint="Tab to switch modes"
68
+ onClose={handleClose}
69
+ onSelect={handleSelect}
70
+ theme={theme}
71
+ />
72
+ ),
73
+ [modalItems, handleClose, handleSelect, theme],
74
+ );
75
+
76
+ useEffect(() => {
77
+ const cleanup = register([
78
+ {
79
+ name: '/model',
80
+ title: 'Select Model',
81
+ category: 'configuration',
82
+ onExecute: async (dialog) => {
83
+ const result = await fetchModelItems();
84
+ if (result.messages !== undefined && result.messages.length > 0) {
85
+ appendMessage('system', result.messages.join('\n'));
86
+ }
87
+ if (result.items.length === 0) {
88
+ return;
89
+ }
90
+
91
+ dialogClearRef.current = dialog.clear;
92
+ setModalItems(result.items);
93
+ dialog.replace(modal);
94
+ },
95
+ },
96
+ ]);
97
+
98
+ return cleanup;
99
+ }, [register, fetchModelItems, appendMessage, modal]);
100
+
101
+ return null;
102
+ }
@@ -0,0 +1,103 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
8
+ import { useCommand } from '../uicontext';
9
+ import { SearchSelectModal } from '../ui/modals';
10
+ import type { SearchItem } from '../ui/modals/types';
11
+ import type { SessionConfig } from '../features/config';
12
+ import type { ThemeDefinition } from '../features/theme';
13
+
14
+ interface ProviderCommandProps {
15
+ readonly fetchProviderItems: () => Promise<{
16
+ items: SearchItem[];
17
+ messages?: string[];
18
+ }>;
19
+ readonly sessionConfig: SessionConfig;
20
+ readonly setSessionConfig: (config: SessionConfig) => void;
21
+ readonly appendMessage: (
22
+ role: 'user' | 'model' | 'system',
23
+ text: string,
24
+ ) => string;
25
+ readonly theme: ThemeDefinition;
26
+ readonly focusInput: () => void;
27
+ }
28
+
29
+ export function ProviderCommand({
30
+ fetchProviderItems,
31
+ sessionConfig,
32
+ setSessionConfig,
33
+ appendMessage,
34
+ theme,
35
+ focusInput,
36
+ }: ProviderCommandProps): React.ReactNode | null {
37
+ const { register } = useCommand();
38
+ const dialogClearRef = useRef<(() => void) | null>(null);
39
+ const [modalItems, setModalItems] = useState<SearchItem[]>([]);
40
+
41
+ const handleClose = useCallback((): void => {
42
+ if (dialogClearRef.current !== null) {
43
+ dialogClearRef.current();
44
+ }
45
+ focusInput();
46
+ }, [focusInput]);
47
+
48
+ const handleSelect = useCallback(
49
+ (item: SearchItem): void => {
50
+ const id = item.id.toLowerCase();
51
+ setSessionConfig({ ...sessionConfig, provider: id });
52
+ appendMessage('system', `Selected provider: ${item.label}`);
53
+ if (dialogClearRef.current !== null) {
54
+ dialogClearRef.current();
55
+ }
56
+ focusInput();
57
+ },
58
+ [sessionConfig, setSessionConfig, appendMessage, focusInput],
59
+ );
60
+
61
+ const modal = useMemo(
62
+ () => (
63
+ <SearchSelectModal
64
+ title="Select Provider"
65
+ noun="providers"
66
+ items={modalItems}
67
+ alphabetical
68
+ footerHint="Tab to switch modes"
69
+ onClose={handleClose}
70
+ onSelect={handleSelect}
71
+ theme={theme}
72
+ />
73
+ ),
74
+ [modalItems, handleClose, handleSelect, theme],
75
+ );
76
+
77
+ useEffect(() => {
78
+ const cleanup = register([
79
+ {
80
+ name: '/provider',
81
+ title: 'Select Provider',
82
+ category: 'configuration',
83
+ onExecute: async (dialog) => {
84
+ const result = await fetchProviderItems();
85
+ if (result.messages !== undefined && result.messages.length > 0) {
86
+ appendMessage('system', result.messages.join('\n'));
87
+ }
88
+ if (result.items.length === 0) {
89
+ return;
90
+ }
91
+
92
+ dialogClearRef.current = dialog.clear;
93
+ setModalItems(result.items);
94
+ dialog.replace(modal);
95
+ },
96
+ },
97
+ ]);
98
+
99
+ return cleanup;
100
+ }, [register, fetchProviderItems, appendMessage, modal]);
101
+
102
+ return null;
103
+ }
@@ -0,0 +1,71 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
2
+ import { useCommand } from '../uicontext';
3
+ import { ThemeModal } from '../ui/modals';
4
+ import type { ThemeDefinition } from '../features/theme';
5
+
6
+ interface ThemeCommandProps {
7
+ readonly themes: ThemeDefinition[];
8
+ readonly currentTheme: ThemeDefinition;
9
+ readonly onThemeSelect: (theme: ThemeDefinition) => void;
10
+ readonly appendMessage: (
11
+ role: 'user' | 'model' | 'system',
12
+ text: string,
13
+ ) => string;
14
+ readonly focusInput: () => void;
15
+ }
16
+
17
+ export function ThemeCommand({
18
+ themes,
19
+ currentTheme,
20
+ onThemeSelect,
21
+ appendMessage,
22
+ focusInput,
23
+ }: ThemeCommandProps): React.ReactNode | null {
24
+ const { register } = useCommand();
25
+ const dialogClearRef = useRef<(() => void) | null>(null);
26
+
27
+ const handleClose = useCallback((): void => {
28
+ if (dialogClearRef.current !== null) {
29
+ dialogClearRef.current();
30
+ }
31
+ focusInput();
32
+ }, [focusInput]);
33
+
34
+ const handleSelect = useCallback(
35
+ (theme: ThemeDefinition): void => {
36
+ onThemeSelect(theme);
37
+ appendMessage('system', `Theme set to ${theme.name}`);
38
+ },
39
+ [onThemeSelect, appendMessage],
40
+ );
41
+
42
+ const modal = useMemo(
43
+ () => (
44
+ <ThemeModal
45
+ themes={themes}
46
+ current={currentTheme}
47
+ onClose={handleClose}
48
+ onSelect={handleSelect}
49
+ />
50
+ ),
51
+ [themes, currentTheme, handleClose, handleSelect],
52
+ );
53
+
54
+ useEffect(() => {
55
+ const cleanup = register([
56
+ {
57
+ name: '/theme',
58
+ title: 'Select Theme',
59
+ category: 'appearance',
60
+ onExecute: (dialog) => {
61
+ dialogClearRef.current = dialog.clear;
62
+ dialog.replace(modal);
63
+ },
64
+ },
65
+ ]);
66
+
67
+ return cleanup;
68
+ }, [register, modal]);
69
+
70
+ return null;
71
+ }
@@ -0,0 +1,178 @@
1
+ import type { TextareaRenderable } from '@vybestack/opentui-core';
2
+ import { useCallback, useEffect, useRef, type RefObject } from 'react';
3
+ import type { PersistentHistoryService } from './persistentHistory';
4
+
5
+ type Direction = 'up' | 'down';
6
+
7
+ export interface UsePromptHistoryOptions {
8
+ /** Optional persistent history service for cross-session history */
9
+ persistentHistory?: PersistentHistoryService | null;
10
+ }
11
+
12
+ /**
13
+ * Calculate which line the cursor is on (0-indexed) based on cursor offset
14
+ */
15
+ function getCursorLine(text: string, cursorOffset: number): number {
16
+ const textBeforeCursor = text.slice(0, cursorOffset);
17
+ const newlineMatches = textBeforeCursor.match(/\n/g);
18
+ return newlineMatches ? newlineMatches.length : 0;
19
+ }
20
+
21
+ /**
22
+ * Get the total number of lines in text (1-indexed count)
23
+ */
24
+ function getLineCount(text: string): number {
25
+ const newlineMatches = text.match(/\n/g);
26
+ return (newlineMatches ? newlineMatches.length : 0) + 1;
27
+ }
28
+
29
+ export function usePromptHistory(
30
+ textareaRef: RefObject<TextareaRenderable | null>,
31
+ options?: UsePromptHistoryOptions,
32
+ ): {
33
+ record: (prompt: string) => void;
34
+ handleHistoryKey: (direction: Direction) => boolean;
35
+ } {
36
+ // Session entries are stored oldest-first for easy navigation
37
+ const sessionEntries = useRef<string[]>([]);
38
+ // Combined history: persistent (newest-first reversed) + session entries
39
+ const combinedEntries = useRef<string[]>([]);
40
+ const index = useRef<number>(0);
41
+ const lastKey = useRef<{ direction: Direction; time: number } | null>(null);
42
+ const persistentHistoryRef = useRef(options?.persistentHistory);
43
+ // Draft text saved when user starts navigating history
44
+ const draft = useRef<string | null>(null);
45
+ // Track if we're currently navigating history
46
+ const isNavigating = useRef<boolean>(false);
47
+
48
+ // Keep ref in sync
49
+ useEffect(() => {
50
+ persistentHistoryRef.current = options?.persistentHistory;
51
+ }, [options?.persistentHistory]);
52
+
53
+ // Load persistent history on mount or when service changes
54
+ useEffect(() => {
55
+ const persistent = options?.persistentHistory;
56
+ if (persistent) {
57
+ // Persistent history is newest-first, reverse it for our oldest-first array
58
+ const persistentEntries = [...persistent.getHistory()].reverse();
59
+ combinedEntries.current = [
60
+ ...persistentEntries,
61
+ ...sessionEntries.current,
62
+ ];
63
+ index.current = combinedEntries.current.length;
64
+ } else {
65
+ combinedEntries.current = [...sessionEntries.current];
66
+ index.current = combinedEntries.current.length;
67
+ }
68
+ }, [options?.persistentHistory]);
69
+
70
+ const record = useCallback((prompt: string) => {
71
+ // Add to session entries
72
+ sessionEntries.current.push(prompt);
73
+
74
+ // Update combined entries
75
+ const persistent = persistentHistoryRef.current;
76
+ if (persistent) {
77
+ const persistentEntries = [...persistent.getHistory()].reverse();
78
+ combinedEntries.current = [
79
+ ...persistentEntries,
80
+ ...sessionEntries.current,
81
+ ];
82
+ } else {
83
+ combinedEntries.current = [...sessionEntries.current];
84
+ }
85
+ index.current = combinedEntries.current.length;
86
+
87
+ // Reset navigation state
88
+ draft.current = null;
89
+ isNavigating.current = false;
90
+
91
+ // Record to persistent storage (async, fire-and-forget)
92
+ if (persistent) {
93
+ void persistent.record(prompt);
94
+ }
95
+ }, []);
96
+
97
+ const applyEntry = useCallback(
98
+ (direction: Direction) => {
99
+ if (combinedEntries.current.length === 0) {
100
+ return false;
101
+ }
102
+ if (textareaRef.current == null) {
103
+ return false;
104
+ }
105
+
106
+ const currentText = textareaRef.current.plainText;
107
+ const cursorOffset = textareaRef.current.cursorOffset;
108
+ const cursorLine = getCursorLine(currentText, cursorOffset);
109
+ const totalLines = getLineCount(currentText);
110
+
111
+ // Check cursor position requirement:
112
+ // - For "up": cursor must be on the first line (line 0)
113
+ // - For "down": cursor must be on the last line
114
+ if (direction === 'up' && cursorLine !== 0) {
115
+ return false;
116
+ }
117
+ if (direction === 'down' && cursorLine !== totalLines - 1) {
118
+ return false;
119
+ }
120
+
121
+ // Save draft when starting to navigate up
122
+ if (!isNavigating.current && direction === 'up') {
123
+ draft.current = currentText;
124
+ isNavigating.current = true;
125
+ }
126
+
127
+ if (direction === 'up') {
128
+ index.current = Math.max(0, index.current - 1);
129
+ } else {
130
+ index.current = Math.min(
131
+ combinedEntries.current.length,
132
+ index.current + 1,
133
+ );
134
+ }
135
+
136
+ // When navigating down past the end, restore the draft
137
+ if (index.current >= combinedEntries.current.length) {
138
+ const value = draft.current ?? '';
139
+ textareaRef.current.setText(value);
140
+ textareaRef.current.cursorOffset = value.length;
141
+ // Reset navigation state when back at draft
142
+ isNavigating.current = false;
143
+ return true;
144
+ }
145
+
146
+ const value = combinedEntries.current[index.current] ?? '';
147
+ textareaRef.current.setText(value);
148
+ textareaRef.current.cursorOffset = value.length;
149
+ return true;
150
+ },
151
+ [textareaRef],
152
+ );
153
+
154
+ const handleHistoryKey = useCallback(
155
+ (direction: Direction): boolean => {
156
+ // Down arrow: single press when navigating (on last line)
157
+ if (direction === 'down' && isNavigating.current) {
158
+ return applyEntry(direction);
159
+ }
160
+
161
+ // Up arrow: requires double-tap
162
+ const now = Date.now();
163
+ if (
164
+ lastKey.current?.direction === direction &&
165
+ now - lastKey.current.time < 400
166
+ ) {
167
+ const applied = applyEntry(direction);
168
+ lastKey.current = null;
169
+ return applied;
170
+ }
171
+ lastKey.current = { direction, time: now };
172
+ return false;
173
+ },
174
+ [applyEntry],
175
+ );
176
+
177
+ return { record, handleHistoryKey };
178
+ }
@@ -0,0 +1,3 @@
1
+ export * from './responder';
2
+ export * from './history';
3
+ export * from './persistentHistory';
@@ -0,0 +1,102 @@
1
+ import {
2
+ Logger,
3
+ MessageSenderType,
4
+ Storage,
5
+ } from '@vybestack/llxprt-code-core';
6
+ import { getLogger } from '../../lib/logger';
7
+
8
+ const debug = getLogger('nui:persistent-history');
9
+
10
+ /**
11
+ * Service for persistent prompt history that shares data with llxprt-code.
12
+ * History is stored in ~/.llxprt/tmp/{project-hash}/logs.json
13
+ */
14
+ export class PersistentHistoryService {
15
+ private logger: Logger | null = null;
16
+ private initialized = false;
17
+ private cachedHistory: string[] = [];
18
+
19
+ constructor(
20
+ private readonly workingDir: string,
21
+ private readonly sessionId: string,
22
+ ) {}
23
+
24
+ /**
25
+ * Initialize the history service. Must be called before using other methods.
26
+ */
27
+ async initialize(): Promise<void> {
28
+ if (this.initialized) {
29
+ return;
30
+ }
31
+
32
+ try {
33
+ const storage = new Storage(this.workingDir);
34
+ this.logger = new Logger(this.sessionId, storage);
35
+ await this.logger.initialize();
36
+
37
+ // Load existing history
38
+ this.cachedHistory = await this.logger.getPreviousUserMessages();
39
+ debug.debug('Loaded history', 'count:', this.cachedHistory.length);
40
+
41
+ this.initialized = true;
42
+ } catch (err) {
43
+ debug.error('Failed to initialize persistent history:', String(err));
44
+ this.initialized = false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Record a new prompt to persistent storage.
50
+ */
51
+ async record(prompt: string): Promise<void> {
52
+ if (!this.initialized || !this.logger) {
53
+ debug.warn('Cannot record: history service not initialized');
54
+ return;
55
+ }
56
+
57
+ try {
58
+ await this.logger.logMessage(MessageSenderType.USER, prompt);
59
+ // Add to cache (at the beginning since getPreviousUserMessages returns newest first)
60
+ this.cachedHistory.unshift(prompt);
61
+ debug.debug('Recorded prompt to history');
62
+ } catch (err) {
63
+ debug.error('Failed to record prompt:', String(err));
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Get all previous user messages, newest first.
69
+ */
70
+ getHistory(): string[] {
71
+ return this.cachedHistory;
72
+ }
73
+
74
+ /**
75
+ * Get the number of history entries.
76
+ */
77
+ get count(): number {
78
+ return this.cachedHistory.length;
79
+ }
80
+
81
+ /**
82
+ * Close the history service.
83
+ */
84
+ close(): void {
85
+ if (this.logger) {
86
+ this.logger.close();
87
+ this.logger = null;
88
+ }
89
+ this.initialized = false;
90
+ this.cachedHistory = [];
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Create a persistent history service for the given working directory.
96
+ */
97
+ export function createPersistentHistory(
98
+ workingDir: string,
99
+ sessionId: string,
100
+ ): PersistentHistoryService {
101
+ return new PersistentHistoryService(workingDir, sessionId);
102
+ }