@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.
- package/app/components/ChatWorkspace.vue +481 -64
- package/app/components/LocalFileViewerModal.vue +314 -0
- package/app/components/MessagePartRenderer.ts +1 -0
- package/app/components/SubagentTranscriptPanel.vue +5 -1
- package/app/components/UsageStatusModal.vue +265 -0
- package/app/components/VisualSubagentStack.vue +2 -0
- package/app/components/message-part/LocalFileLink.vue +51 -0
- package/app/components/message-part/Text.vue +31 -8
- package/app/composables/useLocalFileViewer.ts +46 -0
- package/app/pages/projects/[...projectId]/threads/[threadId].vue +2 -0
- package/app/utils/slash-prompt-focus.ts +8 -0
- package/package.json +1 -1
- package/server/api/codori/projects/[projectId]/local-file.get.ts +44 -0
- package/shared/account-rate-limits.ts +190 -0
- package/shared/file-autocomplete.ts +166 -0
- package/shared/file-highlighting.ts +127 -0
- package/shared/local-files.ts +122 -0
- package/shared/slash-commands.ts +13 -1
|
@@ -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<
|
|
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
|
|
726
|
-
|
|
727
|
-
|
|
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
|
|
750
|
-
|
|
751
|
-
|
|
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
|
|
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
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
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>
|