feat(app): add @ workspace file autocomplete in agent chat
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyFileMentionReplacement, findActiveFileMention } from './file-mention-autocomplete'
|
||||
|
||||
describe('findActiveFileMention', () => {
|
||||
it('detects mentions at the start of input', () => {
|
||||
const mention = findActiveFileMention({
|
||||
text: '@src/components',
|
||||
cursorIndex: '@src/components'.length,
|
||||
})
|
||||
expect(mention).toEqual({
|
||||
start: 0,
|
||||
end: '@src/components'.length,
|
||||
query: 'src/components',
|
||||
})
|
||||
})
|
||||
|
||||
it('detects mentions in the middle of input using cursor position', () => {
|
||||
const text = 'read "@src/com" before merging'
|
||||
const cursorIndex = text.indexOf('"') + 9
|
||||
const mention = findActiveFileMention({
|
||||
text,
|
||||
cursorIndex,
|
||||
})
|
||||
expect(mention).toEqual({
|
||||
start: text.indexOf('@'),
|
||||
end: cursorIndex,
|
||||
query: 'src/com',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when cursor is outside the mention token', () => {
|
||||
const text = 'please review @src/components now'
|
||||
const mention = findActiveFileMention({
|
||||
text,
|
||||
cursorIndex: text.length,
|
||||
})
|
||||
expect(mention).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyFileMentionReplacement', () => {
|
||||
it('replaces only the active @query segment with a quoted relative path', () => {
|
||||
const text = 'open @src/com next'
|
||||
const next = applyFileMentionReplacement({
|
||||
text,
|
||||
mention: { start: 5, end: 13, query: 'src/com' },
|
||||
relativePath: 'src/components/chat.tsx',
|
||||
})
|
||||
expect(next).toBe('open "src/components/chat.tsx" next')
|
||||
})
|
||||
|
||||
it('escapes double quotes in replacement path', () => {
|
||||
const text = '@foo'
|
||||
const next = applyFileMentionReplacement({
|
||||
text,
|
||||
mention: { start: 0, end: 4, query: 'foo' },
|
||||
relativePath: 'src/"quoted".ts',
|
||||
})
|
||||
expect(next).toBe('"src/\\"quoted\\".ts"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface FileMentionRange {
|
||||
start: number
|
||||
end: number
|
||||
query: string
|
||||
}
|
||||
|
||||
interface FindActiveFileMentionInput {
|
||||
text: string
|
||||
cursorIndex: number
|
||||
}
|
||||
|
||||
interface ApplyFileMentionReplacementInput {
|
||||
text: string
|
||||
mention: FileMentionRange
|
||||
relativePath: string
|
||||
}
|
||||
|
||||
const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/
|
||||
|
||||
export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null {
|
||||
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length))
|
||||
const beforeCursor = input.text.slice(0, clampedCursor)
|
||||
|
||||
for (
|
||||
let atIndex = beforeCursor.lastIndexOf('@');
|
||||
atIndex >= 0;
|
||||
atIndex = beforeCursor.lastIndexOf('@', atIndex - 1)
|
||||
) {
|
||||
const query = beforeCursor.slice(atIndex + 1)
|
||||
if (INVALID_MENTION_QUERY_CHARS.test(query)) {
|
||||
continue
|
||||
}
|
||||
return {
|
||||
start: atIndex,
|
||||
end: clampedCursor,
|
||||
query,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function applyFileMentionReplacement(input: ApplyFileMentionReplacementInput): string {
|
||||
const safePath = input.relativePath.replace(/"/g, '\\"')
|
||||
const before = input.text.slice(0, input.mention.start)
|
||||
const after = input.text.slice(input.mention.end)
|
||||
return `${before}"${safePath}"${after}`
|
||||
}
|
||||
Reference in New Issue
Block a user