@codori/client 0.0.8 → 0.0.9

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.
@@ -6,12 +6,13 @@ import {
6
6
  onBeforeUnmount,
7
7
  onMounted,
8
8
  ref,
9
- watch,
10
- type ComponentPublicInstance
9
+ watch
11
10
  } from 'vue'
11
+ import LocalFileViewerModal from './LocalFileViewerModal.vue'
12
12
  import MessageContent from './MessageContent.vue'
13
13
  import ReviewStartDrawer from './ReviewStartDrawer.vue'
14
14
  import PendingUserRequestDrawer from './PendingUserRequestDrawer.vue'
15
+ import UsageStatusModal from './UsageStatusModal.vue'
15
16
  import {
16
17
  reconcileOptimisticUserMessage,
17
18
  removeChatMessage,
@@ -25,6 +26,7 @@ import {
25
26
  shouldRetrySteerWithTurnStart,
26
27
  shouldIgnoreNotificationAfterInterrupt
27
28
  } from '../utils/chat-turn-engagement'
29
+ import { isFocusWithinContainer } from '../utils/slash-prompt-focus'
28
30
  import { useChatAttachments, type DraftAttachment } from '../composables/useChatAttachments'
29
31
  import { usePendingUserRequest } from '../composables/usePendingUserRequest'
30
32
  import { useChatSession, type LiveStream, type SubagentPanelState } from '../composables/useChatSession'
@@ -99,6 +101,16 @@ import {
99
101
  toSlashCommandCompletion,
100
102
  type SlashCommandDefinition
101
103
  } from '~~/shared/slash-commands'
104
+ import {
105
+ buildFileAutocompletePathSegments,
106
+ findActiveFileAutocompleteMatch,
107
+ normalizeFileAutocompleteQuery,
108
+ normalizeFuzzyFileSearchMatches,
109
+ replaceActiveFileAutocompleteMatch,
110
+ toFileAutocompleteHandle,
111
+ type FileAutocompletePathSegment,
112
+ type NormalizedFuzzyFileSearchMatch
113
+ } from '~~/shared/file-autocomplete'
102
114
 
103
115
  const props = defineProps<{
104
116
  projectId: string
@@ -142,7 +154,9 @@ const input = ref('')
142
154
  const chatPromptRef = ref<{
143
155
  textareaRef?: unknown
144
156
  } | null>(null)
145
- const slashDropdownRef = ref<ComponentPublicInstance | HTMLElement | null>(null)
157
+ const slashDropdownRef = ref<HTMLElement | null>(null)
158
+ const fileAutocompleteDropdownRef = ref<HTMLElement | null>(null)
159
+ const fileAutocompleteListRef = ref<HTMLElement | null>(null)
146
160
  const scrollViewport = ref<HTMLElement | null>(null)
147
161
  const pinnedToBottom = ref(true)
148
162
  const session = useChatSession(props.projectId)
@@ -184,10 +198,16 @@ const promptSelectionStart = ref(0)
184
198
  const promptSelectionEnd = ref(0)
185
199
  const isPromptFocused = ref(false)
186
200
  const dismissedSlashMatchKey = ref<string | null>(null)
201
+ const dismissedFileAutocompleteMatchKey = ref<string | null>(null)
187
202
  const slashHighlightIndex = ref(0)
203
+ const fileAutocompleteHighlightIndex = ref(0)
204
+ const fileAutocompleteLoading = ref(false)
205
+ const fileAutocompleteError = ref<string | null>(null)
206
+ const fileAutocompleteResults = ref<NormalizedFuzzyFileSearchMatch[]>([])
188
207
  const reviewDrawerOpen = ref(false)
189
208
  const reviewDrawerMode = ref<'target' | 'branch'>('target')
190
209
  const reviewDrawerCommandText = ref('/review')
210
+ const usageStatusModalOpen = ref(false)
191
211
  const reviewBranches = ref<string[]>([])
192
212
  const reviewCurrentBranch = ref<string | null>(null)
193
213
  const reviewBranchesLoading = ref(false)
@@ -256,8 +276,48 @@ const filteredSlashCommands = computed(() =>
256
276
  : []
257
277
  )
258
278
 
279
+ const activeFileAutocompleteMatch = computed(() =>
280
+ isPromptFocused.value
281
+ ? findActiveFileAutocompleteMatch(input.value, promptSelectionStart.value, promptSelectionEnd.value)
282
+ : null
283
+ )
284
+
285
+ const activeFileAutocompleteMatchKey = computed(() => {
286
+ const match = activeFileAutocompleteMatch.value
287
+ if (!match) {
288
+ return null
289
+ }
290
+
291
+ return `${match.start}:${match.end}:${match.raw}`
292
+ })
293
+
294
+ const normalizedFileAutocompleteQuery = computed(() =>
295
+ activeFileAutocompleteMatch.value
296
+ ? normalizeFileAutocompleteQuery(activeFileAutocompleteMatch.value.query)
297
+ : ''
298
+ )
299
+
300
+ const fileAutocompleteOpen = computed(() =>
301
+ !reviewDrawerOpen.value
302
+ && Boolean(activeFileAutocompleteMatch.value)
303
+ && activeFileAutocompleteMatchKey.value !== dismissedFileAutocompleteMatchKey.value
304
+ )
305
+
306
+ const visibleFileAutocompleteResults = computed(() =>
307
+ fileAutocompleteResults.value
308
+ .filter((match) => match.matchType === 'file')
309
+ .slice(0, 8)
310
+ )
311
+
312
+ const highlightedFileAutocompleteResult = computed(() =>
313
+ visibleFileAutocompleteResults.value[fileAutocompleteHighlightIndex.value]
314
+ ?? visibleFileAutocompleteResults.value[0]
315
+ ?? null
316
+ )
317
+
259
318
  const slashDropdownOpen = computed(() =>
260
319
  !reviewDrawerOpen.value
320
+ && !fileAutocompleteOpen.value
261
321
  && Boolean(activeSlashMatch.value)
262
322
  && activeSlashMatchKey.value !== dismissedSlashMatchKey.value
263
323
  && filteredSlashCommands.value.length > 0
@@ -267,20 +327,6 @@ const highlightedSlashCommand = computed(() =>
267
327
  filteredSlashCommands.value[slashHighlightIndex.value] ?? filteredSlashCommands.value[0] ?? null
268
328
  )
269
329
 
270
- const slashPaletteGroups = computed(() => [{
271
- id: 'slash-commands',
272
- ignoreFilter: true,
273
- items: filteredSlashCommands.value.map((command, index) => ({
274
- label: `/${command.name}`,
275
- description: command.description,
276
- icon: command.name === 'review' ? 'i-lucide-search-check' : 'i-lucide-terminal',
277
- active: index === slashHighlightIndex.value,
278
- onSelect: () => {
279
- void activateSlashCommand(command, command.supportsInlineArgs ? 'complete' : 'execute')
280
- }
281
- }))
282
- }])
283
-
284
330
  const reviewBaseBranches = computed(() =>
285
331
  reviewBranches.value.filter(branch => branch !== reviewCurrentBranch.value)
286
332
  )
@@ -340,6 +386,34 @@ const contextIndicatorLabel = computed(() => {
340
386
  return remainingPercent == null ? 'ctx' : `${Math.round(remainingPercent)}%`
341
387
  })
342
388
 
389
+ const fileAutocompleteEmptyState = computed(() => {
390
+ if (!fileAutocompleteOpen.value) {
391
+ return null
392
+ }
393
+
394
+ if (!selectedProject.value?.projectPath) {
395
+ return 'Project runtime is not ready yet.'
396
+ }
397
+
398
+ if (!normalizedFileAutocompleteQuery.value) {
399
+ return 'Type a path or filename to search project files.'
400
+ }
401
+
402
+ if (fileAutocompleteLoading.value) {
403
+ return 'Searching project files...'
404
+ }
405
+
406
+ if (fileAutocompleteError.value) {
407
+ return fileAutocompleteError.value
408
+ }
409
+
410
+ if (visibleFileAutocompleteResults.value.length === 0) {
411
+ return 'No matching project files.'
412
+ }
413
+
414
+ return null
415
+ })
416
+
343
417
  const normalizePromptSelection = (
344
418
  preferredModel?: string | null,
345
419
  preferredEffort?: ReasoningEffort | null
@@ -434,6 +508,7 @@ const optimisticAttachmentSnapshots = new Map<string, DraftAttachment[]>()
434
508
  let promptControlsPromise: Promise<void> | null = null
435
509
  let pendingThreadHydration: Promise<void> | null = null
436
510
  let releaseServerRequestHandler: (() => void) | null = null
511
+ let fileAutocompleteRequestSequence = 0
437
512
 
438
513
  const isActiveTurnStatus = (value: string | null | undefined) => {
439
514
  if (!value) {
@@ -705,6 +780,10 @@ const syncPromptSelectionFromDom = () => {
705
780
  if (!activeSlashMatchKey.value || activeSlashMatchKey.value !== dismissedSlashMatchKey.value) {
706
781
  dismissedSlashMatchKey.value = null
707
782
  }
783
+
784
+ if (!activeFileAutocompleteMatchKey.value || activeFileAutocompleteMatchKey.value !== dismissedFileAutocompleteMatchKey.value) {
785
+ dismissedFileAutocompleteMatchKey.value = null
786
+ }
708
787
  }
709
788
 
710
789
  const focusPromptAt = async (position?: number) => {
@@ -722,23 +801,15 @@ const focusPromptAt = async (position?: number) => {
722
801
  isPromptFocused.value = true
723
802
  }
724
803
 
725
- const resolveSlashDropdownElement = () => {
726
- const target = slashDropdownRef.value
727
- if (target instanceof HTMLElement) {
728
- return target
729
- }
730
-
731
- const rootElement = target?.$el
732
- return rootElement instanceof HTMLElement ? rootElement : null
733
- }
734
-
735
- const shouldRetainPromptFocus = (nextFocused: EventTarget | null) =>
736
- nextFocused instanceof Node
737
- && Boolean(resolveSlashDropdownElement()?.contains(nextFocused))
804
+ const shouldRetainPromptFocus = (nextFocused: EventTarget | null | undefined) =>
805
+ isFocusWithinContainer(nextFocused, slashDropdownRef.value)
806
+ || isFocusWithinContainer(nextFocused, fileAutocompleteDropdownRef.value)
738
807
 
739
808
  const handlePromptBlur = (event: FocusEvent) => {
809
+ // Composer suggestion popups must stay focusless. If focus ever moves into
810
+ // the overlay, the textarea selection state no longer matches the active
811
+ // token and the menu collapses while the draft still contains `/` or `@`.
740
812
  if (shouldRetainPromptFocus(event.relatedTarget)) {
741
- isPromptFocused.value = true
742
813
  void focusPromptAt(promptSelectionStart.value)
743
814
  return
744
815
  }
@@ -746,16 +817,38 @@ const handlePromptBlur = (event: FocusEvent) => {
746
817
  isPromptFocused.value = false
747
818
  }
748
819
 
749
- const handleSlashDropdownFocusIn = () => {
750
- if (!slashDropdownOpen.value) {
751
- return
752
- }
820
+ const handleSlashDropdownPointerDown = (event: PointerEvent) => {
821
+ // Fundamental rule for slash commands: the popup is only a visual chooser.
822
+ // Selection happens through the textarea's keyboard handlers, and mouse/touch
823
+ // clicks must not transfer DOM focus into popup items.
824
+ event.preventDefault()
825
+ void focusPromptAt(promptSelectionStart.value)
826
+ }
753
827
 
828
+ const handleFileAutocompletePointerDown = (event: PointerEvent) => {
829
+ // File suggestions follow the same inert-overlay rule as slash commands.
830
+ // Keeping focus anchored in the textarea avoids collapsing the popup while
831
+ // the active `@token` is still being edited.
832
+ event.preventDefault()
754
833
  void focusPromptAt(promptSelectionStart.value)
755
834
  }
756
835
 
757
- const preventSlashPaletteEntryFocus = (event: CustomEvent) => {
836
+ const onPromptEnterCapture = (event: KeyboardEvent) => {
837
+ if (!fileAutocompleteOpen.value) {
838
+ return
839
+ }
840
+
841
+ // UChatPrompt wires its own `keydown.enter.exact` handler on the textarea and
842
+ // emits `submit` immediately. Intercept exact Enter in capture phase so file
843
+ // palette selection never falls through to the component's submit path.
758
844
  event.preventDefault()
845
+ event.stopPropagation()
846
+ event.stopImmediatePropagation()
847
+
848
+ const match = highlightedFileAutocompleteResult.value
849
+ if (match) {
850
+ void selectFileAutocompleteResult(match)
851
+ }
759
852
  }
760
853
 
761
854
  const setComposerError = (messageText: string) => {
@@ -784,6 +877,15 @@ const openReviewDrawer = (commandText = '/review') => {
784
877
  reviewDrawerOpen.value = true
785
878
  }
786
879
 
880
+ const clearSlashCommandDraft = () => {
881
+ // Successful slash commands consume the draft immediately. Leaving `/usage`
882
+ // or `/review` in the prompt after opening the modal/drawer makes the next
883
+ // edit start from stale command text and feels like the action did not finish.
884
+ input.value = ''
885
+ clearAttachments({ revoke: false })
886
+ dismissedSlashMatchKey.value = null
887
+ }
888
+
787
889
  const moveSlashHighlight = (delta: number) => {
788
890
  if (!filteredSlashCommands.value.length) {
789
891
  slashHighlightIndex.value = 0
@@ -805,6 +907,39 @@ const moveSlashHighlight = (delta: number) => {
805
907
  slashHighlightIndex.value = nextIndex
806
908
  }
807
909
 
910
+ const moveFileAutocompleteHighlight = (delta: number) => {
911
+ if (!visibleFileAutocompleteResults.value.length) {
912
+ fileAutocompleteHighlightIndex.value = 0
913
+ return
914
+ }
915
+
916
+ const maxIndex = visibleFileAutocompleteResults.value.length - 1
917
+ const nextIndex = fileAutocompleteHighlightIndex.value + delta
918
+ if (nextIndex < 0) {
919
+ fileAutocompleteHighlightIndex.value = maxIndex
920
+ return
921
+ }
922
+
923
+ if (nextIndex > maxIndex) {
924
+ fileAutocompleteHighlightIndex.value = 0
925
+ return
926
+ }
927
+
928
+ fileAutocompleteHighlightIndex.value = nextIndex
929
+ }
930
+
931
+ const resolveSlashCommandIcon = (command: SlashCommandDefinition) => {
932
+ if (command.name === 'review') {
933
+ return 'i-lucide-search-check'
934
+ }
935
+
936
+ if (command.name === 'usage' || command.name === 'status') {
937
+ return 'i-lucide-gauge'
938
+ }
939
+
940
+ return 'i-lucide-terminal'
941
+ }
942
+
808
943
  const completeSlashCommand = async (command: SlashCommandDefinition) => {
809
944
  const match = activeSlashMatch.value
810
945
  if (!match) {
@@ -817,6 +952,40 @@ const completeSlashCommand = async (command: SlashCommandDefinition) => {
817
952
  await focusPromptAt(match.start + completion.length)
818
953
  }
819
954
 
955
+ const resolveFileAutocompleteIcon = (match: Pick<NormalizedFuzzyFileSearchMatch, 'matchType'>) =>
956
+ match.matchType === 'directory' ? 'i-lucide-folder-open' : 'i-lucide-file-code-2'
957
+
958
+ const renderFileAutocompletePathSegments = (
959
+ match: Pick<NormalizedFuzzyFileSearchMatch, 'path' | 'indices'>
960
+ ): FileAutocompletePathSegment[] =>
961
+ buildFileAutocompletePathSegments(match)
962
+
963
+ const selectFileAutocompleteResult = async (match: NormalizedFuzzyFileSearchMatch) => {
964
+ const activeMatch = activeFileAutocompleteMatch.value
965
+ if (!activeMatch) {
966
+ return
967
+ }
968
+
969
+ dismissedFileAutocompleteMatchKey.value = null
970
+ fileAutocompleteError.value = null
971
+ const replacement = toFileAutocompleteHandle(match)
972
+ const nextDraft = replaceActiveFileAutocompleteMatch(input.value, activeMatch, replacement)
973
+ input.value = nextDraft.value
974
+ fileAutocompleteResults.value = []
975
+ await focusPromptAt(nextDraft.caret)
976
+ }
977
+
978
+ const syncFileAutocompleteScroll = async () => {
979
+ await nextTick()
980
+
981
+ const selected = fileAutocompleteListRef.value?.querySelector<HTMLElement>(
982
+ '[data-file-autocomplete-option][aria-selected="true"]'
983
+ )
984
+ selected?.scrollIntoView({
985
+ block: 'nearest'
986
+ })
987
+ }
988
+
820
989
  const fetchProjectGitBranches = async () => {
821
990
  reviewBranchesLoading.value = true
822
991
  reviewBranchesError.value = null
@@ -928,16 +1097,36 @@ const handleSlashCommandSubmission = async (
928
1097
  return true
929
1098
  }
930
1099
 
931
- if (!slashCommand.isBare) {
932
- setComposerError('`/review` currently only supports the bare command. Choose the diff target in the review drawer.')
933
- return true
934
- }
1100
+ switch (command.name) {
1101
+ case 'review': {
1102
+ if (!slashCommand.isBare) {
1103
+ setComposerError('`/review` currently only supports the bare command. Choose the diff target in the review drawer.')
1104
+ return true
1105
+ }
935
1106
 
936
- error.value = null
937
- status.value = 'ready'
938
- openReviewDrawer(`/${command.name}`)
939
- await focusPromptAt(rawText.trim().length)
940
- return true
1107
+ error.value = null
1108
+ status.value = 'ready'
1109
+ clearSlashCommandDraft()
1110
+ openReviewDrawer(rawText.trim())
1111
+ await focusPromptAt(0)
1112
+ return true
1113
+ }
1114
+ case 'usage':
1115
+ case 'status': {
1116
+ if (!slashCommand.isBare) {
1117
+ setComposerError(`\`/${command.name}\` currently only supports the bare command.`)
1118
+ return true
1119
+ }
1120
+
1121
+ error.value = null
1122
+ status.value = 'ready'
1123
+ clearSlashCommandDraft()
1124
+ usageStatusModalOpen.value = true
1125
+ return true
1126
+ }
1127
+ default:
1128
+ return false
1129
+ }
941
1130
  }
942
1131
 
943
1132
  const activateSlashCommand = async (
@@ -961,6 +1150,10 @@ const handleReviewDrawerOpenChange = (open: boolean) => {
961
1150
  closeReviewDrawer()
962
1151
  }
963
1152
 
1153
+ const handleUsageStatusOpenChange = (open: boolean) => {
1154
+ usageStatusModalOpen.value = open
1155
+ }
1156
+
964
1157
  const handleReviewDrawerBack = () => {
965
1158
  reviewDrawerMode.value = 'target'
966
1159
  reviewBranchesError.value = null
@@ -2213,6 +2406,19 @@ const applyNotification = (notification: CodexRpcNotification) => {
2213
2406
  }
2214
2407
 
2215
2408
  const sendMessage = async () => {
2409
+ // Nuxt UI's UChatPrompt emits `submit` directly from its internal
2410
+ // `keydown.enter.exact` handler, so guarding only in our keydown callback is
2411
+ // not sufficient to stop Enter from racing into a send while the file palette
2412
+ // is open. Keep the submit path itself aware of the palette state.
2413
+ if (fileAutocompleteOpen.value) {
2414
+ const match = highlightedFileAutocompleteResult.value
2415
+ if (match) {
2416
+ await selectFileAutocompleteResult(match)
2417
+ }
2418
+
2419
+ return
2420
+ }
2421
+
2216
2422
  if (sendMessageLocked.value || hasPendingRequest.value) {
2217
2423
  return
2218
2424
  }
@@ -2392,6 +2598,47 @@ const respondToPendingRequest = (response: unknown) => {
2392
2598
  }
2393
2599
 
2394
2600
  const onPromptKeydown = (event: KeyboardEvent) => {
2601
+ if (fileAutocompleteOpen.value) {
2602
+ if (event.key === 'ArrowDown') {
2603
+ if (visibleFileAutocompleteResults.value.length === 0) {
2604
+ return
2605
+ }
2606
+
2607
+ event.preventDefault()
2608
+ moveFileAutocompleteHighlight(1)
2609
+ return
2610
+ }
2611
+
2612
+ if (event.key === 'ArrowUp') {
2613
+ if (visibleFileAutocompleteResults.value.length === 0) {
2614
+ return
2615
+ }
2616
+
2617
+ event.preventDefault()
2618
+ moveFileAutocompleteHighlight(-1)
2619
+ return
2620
+ }
2621
+
2622
+ if (event.key === 'Escape') {
2623
+ event.preventDefault()
2624
+ dismissedFileAutocompleteMatchKey.value = activeFileAutocompleteMatchKey.value
2625
+ isPromptFocused.value = true
2626
+ void focusPromptAt(promptSelectionStart.value)
2627
+ return
2628
+ }
2629
+
2630
+ if (event.key === 'Tab') {
2631
+ const match = highlightedFileAutocompleteResult.value
2632
+ if (!match) {
2633
+ return
2634
+ }
2635
+
2636
+ event.preventDefault()
2637
+ void selectFileAutocompleteResult(match)
2638
+ return
2639
+ }
2640
+ }
2641
+
2395
2642
  if (!slashDropdownOpen.value) {
2396
2643
  return
2397
2644
  }
@@ -2432,6 +2679,16 @@ const onPromptEnter = (event: KeyboardEvent) => {
2432
2679
  return
2433
2680
  }
2434
2681
 
2682
+ if (fileAutocompleteOpen.value) {
2683
+ event.preventDefault()
2684
+ const match = highlightedFileAutocompleteResult.value
2685
+ if (match) {
2686
+ void selectFileAutocompleteResult(match)
2687
+ }
2688
+
2689
+ return
2690
+ }
2691
+
2435
2692
  if (slashDropdownOpen.value) {
2436
2693
  const command = highlightedSlashCommand.value
2437
2694
  if (command) {
@@ -2527,6 +2784,29 @@ watch(filteredSlashCommands, (commands) => {
2527
2784
  }
2528
2785
  }, { flush: 'sync' })
2529
2786
 
2787
+ watch(visibleFileAutocompleteResults, (results) => {
2788
+ if (!results.length) {
2789
+ fileAutocompleteHighlightIndex.value = 0
2790
+ return
2791
+ }
2792
+
2793
+ if (fileAutocompleteHighlightIndex.value >= results.length) {
2794
+ fileAutocompleteHighlightIndex.value = 0
2795
+ }
2796
+ }, { flush: 'sync' })
2797
+
2798
+ watch(
2799
+ [fileAutocompleteOpen, fileAutocompleteHighlightIndex, visibleFileAutocompleteResults],
2800
+ async ([open, highlightIndex, results]) => {
2801
+ if (!open || !results.length || highlightIndex >= results.length) {
2802
+ return
2803
+ }
2804
+
2805
+ await syncFileAutocompleteScroll()
2806
+ },
2807
+ { flush: 'post' }
2808
+ )
2809
+
2530
2810
  watch([selectedModel, availableModels], () => {
2531
2811
  const nextSelection = coercePromptSelection(effectiveModelList.value, selectedModel.value, selectedEffort.value)
2532
2812
  if (selectedModel.value !== nextSelection.model) {
@@ -2543,6 +2823,66 @@ watch(input, async () => {
2543
2823
  await nextTick()
2544
2824
  syncPromptSelectionFromDom()
2545
2825
  }, { flush: 'post' })
2826
+
2827
+ watch(
2828
+ [fileAutocompleteOpen, normalizedFileAutocompleteQuery, () => selectedProject.value?.projectPath ?? null],
2829
+ async ([open, query, projectPath]) => {
2830
+ const requestSequence = ++fileAutocompleteRequestSequence
2831
+
2832
+ if (!open) {
2833
+ fileAutocompleteLoading.value = false
2834
+ fileAutocompleteError.value = null
2835
+ fileAutocompleteResults.value = []
2836
+ return
2837
+ }
2838
+
2839
+ if (!projectPath) {
2840
+ fileAutocompleteLoading.value = false
2841
+ fileAutocompleteError.value = null
2842
+ fileAutocompleteResults.value = []
2843
+ return
2844
+ }
2845
+
2846
+ fileAutocompleteError.value = null
2847
+ if (!query) {
2848
+ fileAutocompleteLoading.value = false
2849
+ fileAutocompleteResults.value = []
2850
+ return
2851
+ }
2852
+
2853
+ fileAutocompleteLoading.value = true
2854
+ fileAutocompleteResults.value = []
2855
+
2856
+ try {
2857
+ await ensureProjectRuntime()
2858
+ const response = await getClient(props.projectId).request('fuzzyFileSearch', {
2859
+ query,
2860
+ roots: [projectPath],
2861
+ cancellationToken: `chat-file-autocomplete:${props.projectId}`
2862
+ })
2863
+
2864
+ if (requestSequence !== fileAutocompleteRequestSequence) {
2865
+ return
2866
+ }
2867
+
2868
+ fileAutocompleteResults.value = normalizeFuzzyFileSearchMatches(response)
2869
+ } catch (caughtError) {
2870
+ if (requestSequence !== fileAutocompleteRequestSequence) {
2871
+ return
2872
+ }
2873
+
2874
+ fileAutocompleteResults.value = []
2875
+ fileAutocompleteError.value = caughtError instanceof Error
2876
+ ? caughtError.message
2877
+ : 'Failed to search project files.'
2878
+ } finally {
2879
+ if (requestSequence === fileAutocompleteRequestSequence) {
2880
+ fileAutocompleteLoading.value = false
2881
+ }
2882
+ }
2883
+ },
2884
+ { flush: 'post' }
2885
+ )
2546
2886
  </script>
2547
2887
 
2548
2888
  <template>
@@ -2663,27 +3003,95 @@ watch(input, async () => {
2663
3003
  Drop images to attach them
2664
3004
  </div>
2665
3005
 
2666
- <UCommandPalette
3006
+ <div
3007
+ v-if="fileAutocompleteOpen"
3008
+ ref="fileAutocompleteDropdownRef"
3009
+ role="listbox"
3010
+ aria-label="Project files"
3011
+ class="absolute bottom-full left-0 z-20 mb-2 w-[90vw] max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-default bg-default/95 shadow-2xl backdrop-blur md:w-[min(50vw,52rem)] md:max-w-[min(50vw,52rem)]"
3012
+ @pointerdown="handleFileAutocompletePointerDown"
3013
+ >
3014
+ <div
3015
+ ref="fileAutocompleteListRef"
3016
+ class="max-h-72 overflow-y-auto p-2"
3017
+ >
3018
+ <div
3019
+ v-if="fileAutocompleteEmptyState"
3020
+ class="px-3 py-2 text-sm leading-6 text-muted"
3021
+ >
3022
+ {{ fileAutocompleteEmptyState }}
3023
+ </div>
3024
+ <div
3025
+ v-for="(match, index) in visibleFileAutocompleteResults"
3026
+ v-else
3027
+ :key="`${match.matchType}:${match.path}`"
3028
+ role="option"
3029
+ :aria-selected="index === fileAutocompleteHighlightIndex"
3030
+ data-file-autocomplete-option=""
3031
+ class="group relative flex cursor-pointer items-center gap-1.5 px-3 py-2 text-sm text-highlighted outline-none transition-colors before:absolute before:inset-px before:z-[-1] before:rounded-md"
3032
+ :class="index === fileAutocompleteHighlightIndex
3033
+ ? 'before:bg-elevated'
3034
+ : 'hover:before:bg-elevated/60'"
3035
+ @click="void selectFileAutocompleteResult(match)"
3036
+ >
3037
+ <UIcon
3038
+ :name="resolveFileAutocompleteIcon(match)"
3039
+ class="size-4 shrink-0 text-dimmed group-aria-selected:text-highlighted"
3040
+ />
3041
+ <div class="min-w-0 truncate font-mono text-[13px] leading-6">
3042
+ <span
3043
+ v-for="(segment, segmentIndex) in renderFileAutocompletePathSegments(match)"
3044
+ :key="`${match.path}:${segmentIndex}:${segment.isMatch ? 'match' : 'plain'}`"
3045
+ class="truncate"
3046
+ :class="segment.isMatch ? 'font-semibold text-primary' : 'text-toned'"
3047
+ >
3048
+ {{ segment.text }}
3049
+ </span>
3050
+ </div>
3051
+ </div>
3052
+ </div>
3053
+ </div>
3054
+
3055
+ <div
2667
3056
  v-if="slashDropdownOpen"
2668
3057
  ref="slashDropdownRef"
2669
- class="absolute bottom-full left-0 z-20 mb-2 w-[90vw] max-w-[calc(100vw-2rem)] md:w-[min(50vw,52rem)] md:max-w-[min(50vw,52rem)]"
2670
- :groups="slashPaletteGroups"
2671
- :input="false"
2672
- :autofocus="false"
2673
- :search-term="activeSlashMatch?.query ?? ''"
2674
- :ui="{
2675
- root: 'min-h-0 overflow-hidden rounded-2xl border border-default bg-default/95 shadow-2xl backdrop-blur',
2676
- content: 'max-h-72 overflow-y-auto p-2',
2677
- group: 'space-y-1',
2678
- item: 'rounded-xl px-3 py-2.5',
2679
- itemLeadingIcon: 'size-4',
2680
- itemLabel: 'text-sm font-medium',
2681
- itemDescription: 'text-xs leading-5'
2682
- }"
2683
- @entry-focus="preventSlashPaletteEntryFocus"
2684
- @focusin.capture="handleSlashDropdownFocusIn"
2685
- @mousedown.prevent
2686
- />
3058
+ role="listbox"
3059
+ aria-label="Slash commands"
3060
+ class="absolute bottom-full left-0 z-20 mb-2 w-[90vw] max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-default bg-default/95 shadow-2xl backdrop-blur md:w-[min(50vw,52rem)] md:max-w-[min(50vw,52rem)]"
3061
+ @pointerdown="handleSlashDropdownPointerDown"
3062
+ >
3063
+ <!-- Do not swap this back to a focus-managing listbox/palette. Slash
3064
+ suggestions are anchored to the textarea selection state; the
3065
+ popup must remain an inert overlay or the first `/` can move
3066
+ focus out of the composer and permanently collapse matching. -->
3067
+ <div class="max-h-72 space-y-1 overflow-y-auto p-2">
3068
+ <div
3069
+ v-for="(command, index) in filteredSlashCommands"
3070
+ :key="command.name"
3071
+ role="option"
3072
+ :aria-selected="index === slashHighlightIndex"
3073
+ data-slash-command-option=""
3074
+ class="group relative flex cursor-pointer items-start gap-1.5 px-3 py-2.5 text-sm text-highlighted outline-none transition-colors before:absolute before:inset-px before:z-[-1] before:rounded-md"
3075
+ :class="index === slashHighlightIndex
3076
+ ? 'before:bg-elevated'
3077
+ : 'hover:before:bg-elevated/60'"
3078
+ @click="void activateSlashCommand(command, command.supportsInlineArgs ? 'complete' : 'execute')"
3079
+ >
3080
+ <UIcon
3081
+ :name="resolveSlashCommandIcon(command)"
3082
+ class="mt-0.5 size-4 shrink-0 text-dimmed group-aria-selected:text-highlighted"
3083
+ />
3084
+ <div class="min-w-0">
3085
+ <div class="text-sm font-medium">
3086
+ /{{ command.name }}
3087
+ </div>
3088
+ <div class="text-xs leading-5 text-toned">
3089
+ {{ command.description }}
3090
+ </div>
3091
+ </div>
3092
+ </div>
3093
+ </div>
3094
+ </div>
2687
3095
 
2688
3096
  <UChatPrompt
2689
3097
  ref="chatPromptRef"
@@ -2693,6 +3101,7 @@ watch(input, async () => {
2693
3101
  :disabled="isComposerDisabled"
2694
3102
  autoresize
2695
3103
  @submit.prevent="sendMessage"
3104
+ @keydown.enter.exact.capture="onPromptEnterCapture"
2696
3105
  @keydown="onPromptKeydown"
2697
3106
  @keydown.enter="onPromptEnter"
2698
3107
  @input="syncPromptSelectionFromDom"
@@ -2918,6 +3327,8 @@ watch(input, async () => {
2918
3327
  @respond="respondToPendingRequest"
2919
3328
  />
2920
3329
 
3330
+ <LocalFileViewerModal />
3331
+
2921
3332
  <ReviewStartDrawer
2922
3333
  :open="reviewDrawerOpen"
2923
3334
  :mode="reviewDrawerMode"
@@ -2932,4 +3343,10 @@ watch(input, async () => {
2932
3343
  @choose-base-branch="(branch) => startReview({ type: 'baseBranch', branch })"
2933
3344
  @back="handleReviewDrawerBack"
2934
3345
  />
3346
+
3347
+ <UsageStatusModal
3348
+ :project-id="props.projectId"
3349
+ :open="usageStatusModalOpen"
3350
+ @update:open="handleUsageStatusOpenChange"
3351
+ />
2935
3352
  </template>