mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 11:09:34 +00:00
feat: integrate Pi backend and Pi Web
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
// Pure helpers for the chat input's @ file autocomplete. Mirrors the pi TUI's
|
||||
// behavior: @ triggers at line start or after whitespace, entries are ranked
|
||||
// with the TUI's scoreEntry ladder, and completions insert "@relative/path ".
|
||||
|
||||
export interface AtQueryMatch {
|
||||
/** Index of the "@" character in the text */
|
||||
start: number;
|
||||
/** Text typed after the "@" (quotes stripped); may be empty */
|
||||
query: string;
|
||||
/** True when the token uses the @"..." quoted form */
|
||||
quoted: boolean;
|
||||
}
|
||||
|
||||
export interface FileIndexEntry {
|
||||
/** Path relative to the session cwd, "/"-separated, no trailing slash */
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect an @ file token immediately before the cursor. The @ must be at the
|
||||
* start of the text or preceded by whitespace (same rule as the TUI), so
|
||||
* emails like foo@bar never trigger. Supports the in-progress quoted form
|
||||
* @"my dir/fi so drill-down into space-containing paths keeps working.
|
||||
*/
|
||||
export function extractAtQuery(textBeforeCursor: string): AtQueryMatch | null {
|
||||
const quoted = /(?:^|\s)@"([^"\n]*)$/.exec(textBeforeCursor);
|
||||
if (quoted) {
|
||||
return {
|
||||
start: textBeforeCursor.length - (quoted[1].length + 2),
|
||||
query: quoted[1],
|
||||
quoted: true,
|
||||
};
|
||||
}
|
||||
const plain = /(?:^|\s)@([^\s"]*)$/.exec(textBeforeCursor);
|
||||
if (plain) {
|
||||
return {
|
||||
start: textBeforeCursor.length - (plain[1].length + 1),
|
||||
query: plain[1],
|
||||
quoted: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pathDepth(p: string): number {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < p.length; i++) {
|
||||
if (p[i] === "/") depth++;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the entry list from the server's flat file list, deriving directory
|
||||
* entries from file paths (the index API only returns files). Base order is
|
||||
* shallow-first then alphabetical, which is what an empty @ query shows.
|
||||
*/
|
||||
export function buildEntriesFromFiles(files: string[]): FileIndexEntry[] {
|
||||
const dirs = new Set<string>();
|
||||
for (const f of files) {
|
||||
let idx = f.indexOf("/");
|
||||
while (idx !== -1) {
|
||||
dirs.add(f.slice(0, idx));
|
||||
idx = f.indexOf("/", idx + 1);
|
||||
}
|
||||
}
|
||||
const entries: FileIndexEntry[] = [];
|
||||
for (const d of dirs) entries.push({ path: d, isDir: true });
|
||||
for (const f of files) {
|
||||
if (!f) continue;
|
||||
entries.push({ path: f, isDir: false });
|
||||
}
|
||||
entries.sort((a, b) => pathDepth(a.path) - pathDepth(b.path) || a.path.localeCompare(b.path));
|
||||
return entries;
|
||||
}
|
||||
|
||||
function isSubsequence(needle: string, haystack: string): boolean {
|
||||
if (!needle) return true;
|
||||
let i = 0;
|
||||
for (let j = 0; j < haystack.length && i < needle.length; j++) {
|
||||
if (haystack[j] === needle[i]) i++;
|
||||
}
|
||||
return i === needle.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* TUI scoreEntry ladder (exact 100 / prefix 80 / substring 50 / path substring
|
||||
* 30, directories +10) plus a low-weight subsequence fallback so genuinely
|
||||
* fuzzy queries like "chinp" still find components/ChatInput.tsx.
|
||||
*
|
||||
* Queries containing "/" are ranked against the full relative path instead of
|
||||
* the basename — this is what makes drill-down work: after inserting "@src/",
|
||||
* the query "src/" prefix-matches every entry inside src/ (and excludes the
|
||||
* src directory itself, since "src" does not start with "src/").
|
||||
*/
|
||||
function scoreEntry(entry: FileIndexEntry, lowerQuery: string): number {
|
||||
const lowerPath = entry.path.toLowerCase();
|
||||
let score = 0;
|
||||
if (lowerQuery.includes("/")) {
|
||||
if (lowerPath === lowerQuery) score = 100;
|
||||
else if (lowerPath.startsWith(lowerQuery)) score = 80;
|
||||
else if (lowerPath.includes(lowerQuery)) score = 50;
|
||||
else if (isSubsequence(lowerQuery, lowerPath)) score = 10;
|
||||
} else {
|
||||
const slash = lowerPath.lastIndexOf("/");
|
||||
const lowerName = slash === -1 ? lowerPath : lowerPath.slice(slash + 1);
|
||||
if (lowerName === lowerQuery) score = 100;
|
||||
else if (lowerName.startsWith(lowerQuery)) score = 80;
|
||||
else if (lowerName.includes(lowerQuery)) score = 50;
|
||||
else if (lowerPath.includes(lowerQuery)) score = 30;
|
||||
else if (isSubsequence(lowerQuery, lowerPath)) score = 10;
|
||||
}
|
||||
if (entry.isDir && score > 0) score += 10;
|
||||
return score;
|
||||
}
|
||||
|
||||
export const AT_RESULT_LIMIT = 20;
|
||||
|
||||
export function filterFileEntries(
|
||||
entries: FileIndexEntry[],
|
||||
query: string,
|
||||
limit: number = AT_RESULT_LIMIT,
|
||||
): FileIndexEntry[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
if (!lowerQuery) return entries.slice(0, limit);
|
||||
|
||||
const scored: Array<{ entry: FileIndexEntry; score: number }> = [];
|
||||
for (const entry of entries) {
|
||||
const score = scoreEntry(entry, lowerQuery);
|
||||
if (score > 0) scored.push({ entry, score });
|
||||
}
|
||||
scored.sort((a, b) =>
|
||||
b.score - a.score
|
||||
|| pathDepth(a.entry.path) - pathDepth(b.entry.path)
|
||||
|| a.entry.path.localeCompare(b.entry.path));
|
||||
return scored.slice(0, limit).map((s) => s.entry);
|
||||
}
|
||||
|
||||
export interface AtInsertion {
|
||||
/** Text that replaces the @token */
|
||||
text: string;
|
||||
/** Caret position relative to the start of `text` after insertion */
|
||||
cursorOffset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replacement for the @token when a suggestion is confirmed. Mirrors the
|
||||
* TUI's buildCompletionValue/applyCompletion:
|
||||
* - Files close the token: "@path " (quoted when the path contains spaces),
|
||||
* caret after the trailing space.
|
||||
* - Directories keep the menu open for drill-down: "@dir/" with no trailing
|
||||
* space. Quoted directories are inserted CLOSED (@"my dir/") with the caret
|
||||
* placed before the closing quote, so both further typing and manual
|
||||
* completion keep the token well-formed.
|
||||
*/
|
||||
export function buildAtInsertText(entryPath: string, isDir: boolean, forceQuotes = false): AtInsertion {
|
||||
const p = isDir ? `${entryPath}/` : entryPath;
|
||||
const needsQuotes = forceQuotes || p.includes(" ");
|
||||
if (isDir) {
|
||||
const text = needsQuotes ? `@"${p}"` : `@${p}`;
|
||||
return { text, cursorOffset: needsQuotes ? text.length - 1 : text.length };
|
||||
}
|
||||
const text = needsQuotes ? `@"${p}" ` : `@${p} `;
|
||||
return { text, cursorOffset: text.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed @mention for one-shot inserts (e.g. the file explorer's @ button).
|
||||
* Unlike buildAtInsertText there is no drill-down: directories are closed
|
||||
* too, with a trailing "/" and a trailing space.
|
||||
*/
|
||||
export function buildAtMentionText(entryPath: string, isDir: boolean): string {
|
||||
const p = isDir ? `${entryPath}/` : entryPath;
|
||||
return p.includes(" ") ? `@"${p}" ` : `@${p} `;
|
||||
}
|
||||
|
||||
/** Closed file @mention scoped to one logical line or an inclusive line range. */
|
||||
export function buildFileLineMentionText(entryPath: string, startLine: number, endLine: number): string {
|
||||
const firstLine = Math.max(1, Math.min(startLine, endLine));
|
||||
const lastLine = Math.max(1, Math.max(startLine, endLine));
|
||||
const pathMention = entryPath.includes(" ") ? `@"${entryPath}"` : `@${entryPath}`;
|
||||
const lineSuffix = firstLine === lastLine ? `:${firstLine}` : `:${firstLine}-${lastLine}`;
|
||||
return `${pathMention}${lineSuffix} `;
|
||||
}
|
||||
|
||||
export function buildFileAtMentionsText(entryPaths: string[]): string {
|
||||
return entryPaths.map((entryPath) => buildAtMentionText(entryPath, false)).join("");
|
||||
}
|
||||
Reference in New Issue
Block a user