@jvittechs/jai1-cli 0.1.100 → 0.1.101

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.
@@ -18,6 +18,7 @@
18
18
 
19
19
  const MAX_STORAGE_SIZE = 4.5 * 1024 * 1024;
20
20
  const MAX_CONTEXT_TOKENS = 175000; // 175K token limit
21
+ const MAX_MENTIONED_FILES = 5; // Maximum files that can be mentioned
21
22
 
22
23
  // ============================================
23
24
  // Agent Definitions
@@ -350,6 +351,11 @@ When reviewing code:
350
351
  currentConversationId: null,
351
352
  isStreaming: false,
352
353
  modelStats: null, // { date, models: { [modelId]: { limit, used, remaining } } }
354
+ // File mention state
355
+ mentionedFiles: [], // Array of { path, name, size, language }
356
+ fileSearchResults: [], // Autocomplete results
357
+ isFileSearching: false, // Autocomplete loading state
358
+ fileServiceInfo: null, // { workingDir, maxFiles }
353
359
  };
354
360
 
355
361
  // ============================================
@@ -620,6 +626,31 @@ When reviewing code:
620
626
  reader.releaseLock();
621
627
  }
622
628
  },
629
+
630
+ // === File API Methods ===
631
+
632
+ async getFileServiceInfo() {
633
+ const response = await this.request('/api/files/info');
634
+ return response.json();
635
+ },
636
+
637
+ async searchFiles(query, limit = 10) {
638
+ const response = await this.request(`/api/files/search?q=${encodeURIComponent(query)}&limit=${limit}`);
639
+ return response.json();
640
+ },
641
+
642
+ async readFile(filePath) {
643
+ const response = await this.request(`/api/files/read?path=${encodeURIComponent(filePath)}`);
644
+ return response.json();
645
+ },
646
+
647
+ async readFiles(paths) {
648
+ const response = await this.request('/api/files/batch', {
649
+ method: 'POST',
650
+ body: JSON.stringify({ paths }),
651
+ });
652
+ return response.json();
653
+ },
623
654
  };
624
655
 
625
656
  class APIError extends Error {
@@ -719,6 +750,431 @@ When reviewing code:
719
750
  },
720
751
  };
721
752
 
753
+ // ============================================
754
+ // File Mention System
755
+ // ============================================
756
+ const FileMention = {
757
+ autocompleteEl: null,
758
+ debounceTimer: null,
759
+ selectedIndex: -1,
760
+ mentionStartPos: -1,
761
+ lastQuery: '',
762
+
763
+ init() {
764
+ // Create autocomplete dropdown
765
+ this.createAutocompleteElement();
766
+ // Create mentioned files container
767
+ this.createMentionedFilesContainer();
768
+ // Load file service info
769
+ this.loadFileServiceInfo();
770
+ },
771
+
772
+ createAutocompleteElement() {
773
+ this.autocompleteEl = document.createElement('div');
774
+ this.autocompleteEl.className = 'file-autocomplete hidden';
775
+ this.autocompleteEl.innerHTML = `
776
+ <div class="file-autocomplete-header">
777
+ <span class="autocomplete-icon">šŸ“</span>
778
+ <span class="autocomplete-title">Files</span>
779
+ <span class="autocomplete-hint">↑↓ to navigate, Enter to select, Esc to close</span>
780
+ </div>
781
+ <div class="file-autocomplete-list"></div>
782
+ `;
783
+ // Insert before input container
784
+ const inputArea = document.querySelector('.input-area');
785
+ if (inputArea) {
786
+ inputArea.insertBefore(this.autocompleteEl, inputArea.querySelector('.input-container'));
787
+ }
788
+ },
789
+
790
+ createMentionedFilesContainer() {
791
+ const container = document.createElement('div');
792
+ container.id = 'mentioned-files';
793
+ container.className = 'mentioned-files hidden';
794
+ // Insert before input selectors
795
+ const inputArea = document.querySelector('.input-area');
796
+ const selectors = inputArea?.querySelector('.input-selectors');
797
+ if (inputArea && selectors) {
798
+ inputArea.insertBefore(container, selectors);
799
+ }
800
+ },
801
+
802
+ async loadFileServiceInfo() {
803
+ try {
804
+ const response = await API.getFileServiceInfo();
805
+ if (response.success && response.data) {
806
+ state.fileServiceInfo = response.data;
807
+ }
808
+ } catch (error) {
809
+ console.error('Failed to load file service info:', error);
810
+ }
811
+ },
812
+
813
+ // Detect @ mention in textarea
814
+ detectMention(textarea) {
815
+ const text = textarea.value;
816
+ const cursorPos = textarea.selectionStart;
817
+
818
+ // Find @ before cursor
819
+ let atPos = -1;
820
+ for (let i = cursorPos - 1; i >= 0; i--) {
821
+ const char = text[i];
822
+ if (char === '@') {
823
+ atPos = i;
824
+ break;
825
+ }
826
+ // Stop at whitespace or newline (not a mention)
827
+ if (char === ' ' || char === '\n' || char === '\t') {
828
+ break;
829
+ }
830
+ }
831
+
832
+ if (atPos === -1) {
833
+ this.hideAutocomplete();
834
+ return null;
835
+ }
836
+
837
+ // Extract query after @
838
+ const query = text.substring(atPos + 1, cursorPos);
839
+ this.mentionStartPos = atPos;
840
+ return query;
841
+ },
842
+
843
+ async handleInput(textarea) {
844
+ const query = this.detectMention(textarea);
845
+
846
+ if (query === null) {
847
+ return;
848
+ }
849
+
850
+ // Debounce search
851
+ clearTimeout(this.debounceTimer);
852
+ this.debounceTimer = setTimeout(async () => {
853
+ if (query.length >= 1) {
854
+ await this.searchFiles(query);
855
+ } else {
856
+ this.hideAutocomplete();
857
+ }
858
+ }, 150);
859
+ },
860
+
861
+ async searchFiles(query) {
862
+ if (query === this.lastQuery) return;
863
+ this.lastQuery = query;
864
+
865
+ state.isFileSearching = true;
866
+ this.selectedIndex = 0; // Reset selection for new search
867
+ this.showAutocomplete();
868
+ this.renderLoading();
869
+
870
+ try {
871
+ const response = await API.searchFiles(query, 10);
872
+ if (response.success && response.data) {
873
+ state.fileSearchResults = response.data.files || [];
874
+ this.selectedIndex = state.fileSearchResults.length > 0 ? 0 : -1;
875
+ this.renderResults();
876
+ }
877
+ } catch (error) {
878
+ console.error('File search error:', error);
879
+ this.renderError('Failed to search files');
880
+ } finally {
881
+ state.isFileSearching = false;
882
+ }
883
+ },
884
+
885
+ showAutocomplete() {
886
+ if (this.autocompleteEl) {
887
+ this.autocompleteEl.classList.remove('hidden');
888
+ }
889
+ },
890
+
891
+ hideAutocomplete() {
892
+ if (this.autocompleteEl) {
893
+ this.autocompleteEl.classList.add('hidden');
894
+ }
895
+ this.selectedIndex = -1;
896
+ this.mentionStartPos = -1;
897
+ this.lastQuery = '';
898
+ state.fileSearchResults = [];
899
+ },
900
+
901
+ renderLoading() {
902
+ const list = this.autocompleteEl?.querySelector('.file-autocomplete-list');
903
+ if (list) {
904
+ list.innerHTML = `
905
+ <div class="file-autocomplete-loading">
906
+ <span class="loading-spinner">ā—Œ</span>
907
+ <span>Searching files...</span>
908
+ </div>
909
+ `;
910
+ }
911
+ },
912
+
913
+ renderError(message) {
914
+ const list = this.autocompleteEl?.querySelector('.file-autocomplete-list');
915
+ if (list) {
916
+ list.innerHTML = `
917
+ <div class="file-autocomplete-error">
918
+ <span>⚠</span>
919
+ <span>${escapeHtml(message)}</span>
920
+ </div>
921
+ `;
922
+ }
923
+ },
924
+
925
+ renderResults() {
926
+ const list = this.autocompleteEl?.querySelector('.file-autocomplete-list');
927
+ if (!list) return;
928
+
929
+ if (state.fileSearchResults.length === 0) {
930
+ list.innerHTML = `
931
+ <div class="file-autocomplete-empty">
932
+ <span>No files found</span>
933
+ </div>
934
+ `;
935
+ return;
936
+ }
937
+
938
+ list.innerHTML = '';
939
+ state.fileSearchResults.forEach((file, index) => {
940
+ const item = document.createElement('div');
941
+ item.className = 'file-autocomplete-item' + (index === this.selectedIndex ? ' selected' : '');
942
+ item.dataset.index = index;
943
+ item.innerHTML = `
944
+ <span class="file-icon">${this.getFileIcon(file.extension)}</span>
945
+ <span class="file-path">${escapeHtml(file.path)}</span>
946
+ <span class="file-size">${this.formatFileSize(file.size)}</span>
947
+ `;
948
+ item.addEventListener('click', () => this.selectFile(index));
949
+ item.addEventListener('mouseenter', () => {
950
+ this.selectedIndex = index;
951
+ this.updateSelection();
952
+ });
953
+ list.appendChild(item);
954
+ });
955
+ },
956
+
957
+ getFileIcon(extension) {
958
+ const iconMap = {
959
+ 'ts': 'šŸ“˜', 'tsx': 'šŸ“˜', 'js': 'šŸ“™', 'jsx': 'šŸ“™',
960
+ 'vue': 'šŸ’š', 'php': '🐘', 'py': 'šŸ',
961
+ 'json': 'šŸ“‹', 'md': 'šŸ“', 'css': 'šŸŽØ', 'scss': 'šŸŽØ',
962
+ 'html': '🌐', 'sql': 'šŸ—„ļø', 'sh': 'āš™ļø',
963
+ };
964
+ return iconMap[extension] || 'šŸ“„';
965
+ },
966
+
967
+ formatFileSize(bytes) {
968
+ if (bytes < 1024) return bytes + 'B';
969
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB';
970
+ return (bytes / (1024 * 1024)).toFixed(1) + 'MB';
971
+ },
972
+
973
+ updateSelection() {
974
+ const items = this.autocompleteEl?.querySelectorAll('.file-autocomplete-item');
975
+ items?.forEach((item, index) => {
976
+ item.classList.toggle('selected', index === this.selectedIndex);
977
+ });
978
+ },
979
+
980
+ handleKeydown(e, textarea) {
981
+ if (this.autocompleteEl?.classList.contains('hidden')) {
982
+ return false;
983
+ }
984
+
985
+ // No results to navigate
986
+ if (state.fileSearchResults.length === 0) {
987
+ if (e.key === 'Escape') {
988
+ e.preventDefault();
989
+ this.hideAutocomplete();
990
+ return true;
991
+ }
992
+ return false;
993
+ }
994
+
995
+ switch (e.key) {
996
+ case 'ArrowDown':
997
+ e.preventDefault();
998
+ if (this.selectedIndex < 0) {
999
+ this.selectedIndex = 0;
1000
+ } else {
1001
+ this.selectedIndex = Math.min(this.selectedIndex + 1, state.fileSearchResults.length - 1);
1002
+ }
1003
+ this.updateSelection();
1004
+ this.scrollToSelected();
1005
+ return true;
1006
+
1007
+ case 'ArrowUp':
1008
+ e.preventDefault();
1009
+ if (this.selectedIndex < 0) {
1010
+ this.selectedIndex = state.fileSearchResults.length - 1;
1011
+ } else if (this.selectedIndex > 0) {
1012
+ this.selectedIndex--;
1013
+ }
1014
+ this.updateSelection();
1015
+ this.scrollToSelected();
1016
+ return true;
1017
+
1018
+ case 'Enter':
1019
+ if (this.selectedIndex >= 0) {
1020
+ e.preventDefault();
1021
+ this.selectFile(this.selectedIndex, textarea);
1022
+ return true;
1023
+ } else if (state.fileSearchResults.length > 0) {
1024
+ // Select first item if none selected
1025
+ e.preventDefault();
1026
+ this.selectFile(0, textarea);
1027
+ return true;
1028
+ }
1029
+ break;
1030
+
1031
+ case 'Escape':
1032
+ e.preventDefault();
1033
+ this.hideAutocomplete();
1034
+ return true;
1035
+
1036
+ case 'Tab':
1037
+ if (state.fileSearchResults.length > 0) {
1038
+ e.preventDefault();
1039
+ this.selectFile(Math.max(this.selectedIndex, 0), textarea);
1040
+ return true;
1041
+ }
1042
+ break;
1043
+ }
1044
+
1045
+ return false;
1046
+ },
1047
+
1048
+ scrollToSelected() {
1049
+ const list = this.autocompleteEl?.querySelector('.file-autocomplete-list');
1050
+ const selectedItem = list?.querySelector('.file-autocomplete-item.selected');
1051
+ if (selectedItem && list) {
1052
+ selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1053
+ }
1054
+ },
1055
+
1056
+ selectFile(index, textarea = elements.messageInput) {
1057
+ const file = state.fileSearchResults[index];
1058
+ if (!file) return;
1059
+
1060
+ // Check if already mentioned
1061
+ if (state.mentionedFiles.some(f => f.path === file.path)) {
1062
+ this.hideAutocomplete();
1063
+ // Remove the @query from textarea
1064
+ this.removeMentionText(textarea);
1065
+ return;
1066
+ }
1067
+
1068
+ // Check max files limit
1069
+ if (state.mentionedFiles.length >= MAX_MENTIONED_FILES) {
1070
+ alert(`Maximum ${MAX_MENTIONED_FILES} files can be mentioned per message`);
1071
+ this.hideAutocomplete();
1072
+ return;
1073
+ }
1074
+
1075
+ // Add to mentioned files
1076
+ state.mentionedFiles.push(file);
1077
+ this.renderMentionedFiles();
1078
+
1079
+ // Remove @query from textarea
1080
+ this.removeMentionText(textarea);
1081
+
1082
+ this.hideAutocomplete();
1083
+ textarea.focus();
1084
+ },
1085
+
1086
+ removeMentionText(textarea) {
1087
+ if (this.mentionStartPos >= 0) {
1088
+ const text = textarea.value;
1089
+ const cursorPos = textarea.selectionStart;
1090
+ const newText = text.substring(0, this.mentionStartPos) + text.substring(cursorPos);
1091
+ textarea.value = newText;
1092
+ textarea.selectionStart = textarea.selectionEnd = this.mentionStartPos;
1093
+ // Trigger input event
1094
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
1095
+ }
1096
+ },
1097
+
1098
+ renderMentionedFiles() {
1099
+ const container = document.getElementById('mentioned-files');
1100
+ if (!container) return;
1101
+
1102
+ if (state.mentionedFiles.length === 0) {
1103
+ container.classList.add('hidden');
1104
+ container.innerHTML = '';
1105
+ return;
1106
+ }
1107
+
1108
+ container.classList.remove('hidden');
1109
+ container.innerHTML = `
1110
+ <div class="mentioned-files-header">
1111
+ <span class="mentioned-icon">šŸ“Ž</span>
1112
+ <span class="mentioned-title">Attached Files (${state.mentionedFiles.length}/${MAX_MENTIONED_FILES})</span>
1113
+ </div>
1114
+ <div class="mentioned-files-list">
1115
+ ${state.mentionedFiles.map((file, index) => `
1116
+ <div class="mentioned-file-badge" data-index="${index}">
1117
+ <span class="badge-icon">${this.getFileIcon(file.extension)}</span>
1118
+ <span class="badge-path">${escapeHtml(file.path)}</span>
1119
+ <button class="badge-remove" title="Remove file">Ɨ</button>
1120
+ </div>
1121
+ `).join('')}
1122
+ </div>
1123
+ `;
1124
+
1125
+ // Add remove handlers
1126
+ container.querySelectorAll('.badge-remove').forEach(btn => {
1127
+ btn.addEventListener('click', (e) => {
1128
+ const index = parseInt(e.target.closest('.mentioned-file-badge').dataset.index);
1129
+ this.removeFile(index);
1130
+ });
1131
+ });
1132
+ },
1133
+
1134
+ removeFile(index) {
1135
+ state.mentionedFiles.splice(index, 1);
1136
+ this.renderMentionedFiles();
1137
+ },
1138
+
1139
+ clearFiles() {
1140
+ state.mentionedFiles = [];
1141
+ this.renderMentionedFiles();
1142
+ },
1143
+
1144
+ // Get file contents for mentioned files
1145
+ async getFileContents() {
1146
+ if (state.mentionedFiles.length === 0) {
1147
+ return [];
1148
+ }
1149
+
1150
+ const paths = state.mentionedFiles.map(f => f.path);
1151
+ try {
1152
+ const response = await API.readFiles(paths);
1153
+ if (response.success && response.data) {
1154
+ return response.data.files || [];
1155
+ }
1156
+ } catch (error) {
1157
+ console.error('Failed to read files:', error);
1158
+ }
1159
+ return [];
1160
+ },
1161
+
1162
+ // Format file context for LLM
1163
+ formatFileContext(files) {
1164
+ if (!files || files.length === 0) return '';
1165
+
1166
+ let context = '\n\n[Attached Files]\n\n';
1167
+ files.forEach(file => {
1168
+ if (file.error) {
1169
+ context += `--- ${file.path} ---\n[Error: ${file.error}]\n\n`;
1170
+ } else {
1171
+ context += `--- ${file.path} ---\n\`\`\`${file.language || 'text'}\n${file.content}\n\`\`\`\n\n`;
1172
+ }
1173
+ });
1174
+ return context;
1175
+ },
1176
+ };
1177
+
722
1178
  // ============================================
723
1179
  // UI Rendering
724
1180
  // ============================================
@@ -1182,13 +1638,18 @@ When reviewing code:
1182
1638
 
1183
1639
  // Enter to send (Shift+Enter for new line)
1184
1640
  elements.messageInput.addEventListener('keydown', (e) => {
1641
+ // First check if FileMention wants to handle this key
1642
+ if (FileMention.handleKeydown(e, elements.messageInput)) {
1643
+ return;
1644
+ }
1645
+
1185
1646
  if (e.key === 'Enter' && !e.shiftKey) {
1186
1647
  e.preventDefault();
1187
1648
  handleSend();
1188
1649
  }
1189
1650
  });
1190
1651
 
1191
- // Auto-resize textarea
1652
+ // Auto-resize textarea and handle file mention
1192
1653
  elements.messageInput.addEventListener('input', () => {
1193
1654
  const el = elements.messageInput;
1194
1655
  el.style.height = 'auto';
@@ -1203,6 +1664,9 @@ When reviewing code:
1203
1664
 
1204
1665
  // Enable/disable send button
1205
1666
  elements.sendBtn.disabled = !el.value.trim() || state.isStreaming;
1667
+
1668
+ // Handle file mention detection
1669
+ FileMention.handleInput(el);
1206
1670
  });
1207
1671
 
1208
1672
  // New chat button
@@ -1344,9 +1808,27 @@ When reviewing code:
1344
1808
  elements.messageInput.style.height = 'auto';
1345
1809
  elements.charCount.textContent = '';
1346
1810
 
1811
+ // Get file contents if any files are mentioned
1812
+ let fileContents = [];
1813
+ let fileContextStr = '';
1814
+ const hasMentionedFiles = state.mentionedFiles.length > 0;
1815
+ if (hasMentionedFiles) {
1816
+ fileContents = await FileMention.getFileContents();
1817
+ fileContextStr = FileMention.formatFileContext(fileContents);
1818
+ FileMention.clearFiles(); // Clear after getting contents
1819
+ }
1820
+
1821
+ // Build full message with file context
1822
+ const fullMessage = fileContextStr ? content + fileContextStr : content;
1823
+
1824
+ // Display user message (show original content, not with file context)
1825
+ const displayMessage = hasMentionedFiles
1826
+ ? content + `\n\nšŸ“Ž _${fileContents.length} file(s) attached_`
1827
+ : content;
1828
+
1347
1829
  // Add user message
1348
- Conversations.addMessage('user', content);
1349
- UI.appendMessage('user', content);
1830
+ Conversations.addMessage('user', displayMessage);
1831
+ UI.appendMessage('user', displayMessage);
1350
1832
 
1351
1833
  // Check token limit before sending
1352
1834
  if (UI.checkTokenLimit()) {
@@ -1371,8 +1853,8 @@ When reviewing code:
1371
1853
  const agent = UI.getAgentById(conversation.agent || state.selectedAgent);
1372
1854
  const systemPrompt = agent.systemPrompt;
1373
1855
 
1374
- // Stream response
1375
- for await (const event of API.streamChat(content, state.selectedModel, history, systemPrompt)) {
1856
+ // Stream response (send fullMessage with file context)
1857
+ for await (const event of API.streamChat(fullMessage, state.selectedModel, history, systemPrompt)) {
1376
1858
  if (event.type === 'chunk') {
1377
1859
  fullResponse += event.content;
1378
1860
  UI.updateStreamingMessage(assistantMsgEl, fullResponse);
@@ -1471,6 +1953,9 @@ When reviewing code:
1471
1953
  // Setup event listeners
1472
1954
  setupEventListeners();
1473
1955
 
1956
+ // Initialize file mention system
1957
+ FileMention.init();
1958
+
1474
1959
  // Load stats (only once at init)
1475
1960
  loadStats();
1476
1961