@draht/tui 2026.3.2-2
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/README.md +761 -0
- package/dist/autocomplete.d.ts +50 -0
- package/dist/autocomplete.d.ts.map +1 -0
- package/dist/autocomplete.js +596 -0
- package/dist/autocomplete.js.map +1 -0
- package/dist/components/box.d.ts +22 -0
- package/dist/components/box.d.ts.map +1 -0
- package/dist/components/box.js +104 -0
- package/dist/components/box.js.map +1 -0
- package/dist/components/cancellable-loader.d.ts +22 -0
- package/dist/components/cancellable-loader.d.ts.map +1 -0
- package/dist/components/cancellable-loader.js +35 -0
- package/dist/components/cancellable-loader.js.map +1 -0
- package/dist/components/editor.d.ts +205 -0
- package/dist/components/editor.d.ts.map +1 -0
- package/dist/components/editor.js +1679 -0
- package/dist/components/editor.js.map +1 -0
- package/dist/components/image.d.ts +28 -0
- package/dist/components/image.d.ts.map +1 -0
- package/dist/components/image.js +69 -0
- package/dist/components/image.js.map +1 -0
- package/dist/components/input.d.ts +37 -0
- package/dist/components/input.d.ts.map +1 -0
- package/dist/components/input.js +433 -0
- package/dist/components/input.js.map +1 -0
- package/dist/components/loader.d.ts +21 -0
- package/dist/components/loader.d.ts.map +1 -0
- package/dist/components/loader.js +49 -0
- package/dist/components/loader.js.map +1 -0
- package/dist/components/markdown.d.ts +95 -0
- package/dist/components/markdown.d.ts.map +1 -0
- package/dist/components/markdown.js +629 -0
- package/dist/components/markdown.js.map +1 -0
- package/dist/components/select-list.d.ts +32 -0
- package/dist/components/select-list.d.ts.map +1 -0
- package/dist/components/select-list.js +152 -0
- package/dist/components/select-list.js.map +1 -0
- package/dist/components/settings-list.d.ts +50 -0
- package/dist/components/settings-list.d.ts.map +1 -0
- package/dist/components/settings-list.js +185 -0
- package/dist/components/settings-list.js.map +1 -0
- package/dist/components/spacer.d.ts +12 -0
- package/dist/components/spacer.d.ts.map +1 -0
- package/dist/components/spacer.js +23 -0
- package/dist/components/spacer.js.map +1 -0
- package/dist/components/text.d.ts +19 -0
- package/dist/components/text.d.ts.map +1 -0
- package/dist/components/text.js +89 -0
- package/dist/components/text.js.map +1 -0
- package/dist/components/truncated-text.d.ts +13 -0
- package/dist/components/truncated-text.d.ts.map +1 -0
- package/dist/components/truncated-text.js +51 -0
- package/dist/components/truncated-text.js.map +1 -0
- package/dist/editor-component.d.ts +39 -0
- package/dist/editor-component.d.ts.map +1 -0
- package/dist/editor-component.js +2 -0
- package/dist/editor-component.js.map +1 -0
- package/dist/fuzzy.d.ts +16 -0
- package/dist/fuzzy.d.ts.map +1 -0
- package/dist/fuzzy.js +107 -0
- package/dist/fuzzy.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/keybindings.d.ts +39 -0
- package/dist/keybindings.d.ts.map +1 -0
- package/dist/keybindings.js +114 -0
- package/dist/keybindings.js.map +1 -0
- package/dist/keys.d.ts +160 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +959 -0
- package/dist/keys.js.map +1 -0
- package/dist/kill-ring.d.ts +28 -0
- package/dist/kill-ring.d.ts.map +1 -0
- package/dist/kill-ring.js +44 -0
- package/dist/kill-ring.js.map +1 -0
- package/dist/stdin-buffer.d.ts +48 -0
- package/dist/stdin-buffer.d.ts.map +1 -0
- package/dist/stdin-buffer.js +317 -0
- package/dist/stdin-buffer.js.map +1 -0
- package/dist/terminal-image.d.ts +68 -0
- package/dist/terminal-image.d.ts.map +1 -0
- package/dist/terminal-image.js +288 -0
- package/dist/terminal-image.js.map +1 -0
- package/dist/terminal.d.ts +78 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +249 -0
- package/dist/terminal.js.map +1 -0
- package/dist/tui.d.ts +210 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +955 -0
- package/dist/tui.js.map +1 -0
- package/dist/undo-stack.d.ts +17 -0
- package/dist/undo-stack.d.ts.map +1 -0
- package/dist/undo-stack.js +25 -0
- package/dist/undo-stack.js.map +1 -0
- package/dist/utils.d.ts +78 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +800 -0
- package/dist/utils.js.map +1 -0
- package/package.json +53 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface AutocompleteItem {
|
|
2
|
+
value: string;
|
|
3
|
+
label: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SlashCommand {
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
|
|
10
|
+
}
|
|
11
|
+
export interface AutocompleteProvider {
|
|
12
|
+
getSuggestions(lines: string[], cursorLine: number, cursorCol: number): {
|
|
13
|
+
items: AutocompleteItem[];
|
|
14
|
+
prefix: string;
|
|
15
|
+
} | null;
|
|
16
|
+
applyCompletion(lines: string[], cursorLine: number, cursorCol: number, item: AutocompleteItem, prefix: string): {
|
|
17
|
+
lines: string[];
|
|
18
|
+
cursorLine: number;
|
|
19
|
+
cursorCol: number;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export declare class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
23
|
+
private commands;
|
|
24
|
+
private basePath;
|
|
25
|
+
private fdPath;
|
|
26
|
+
constructor(commands?: (SlashCommand | AutocompleteItem)[], basePath?: string, fdPath?: string | null);
|
|
27
|
+
getSuggestions(lines: string[], cursorLine: number, cursorCol: number): {
|
|
28
|
+
items: AutocompleteItem[];
|
|
29
|
+
prefix: string;
|
|
30
|
+
} | null;
|
|
31
|
+
applyCompletion(lines: string[], cursorLine: number, cursorCol: number, item: AutocompleteItem, prefix: string): {
|
|
32
|
+
lines: string[];
|
|
33
|
+
cursorLine: number;
|
|
34
|
+
cursorCol: number;
|
|
35
|
+
};
|
|
36
|
+
private extractAtPrefix;
|
|
37
|
+
private extractPathPrefix;
|
|
38
|
+
private expandHomePath;
|
|
39
|
+
private resolveScopedFuzzyQuery;
|
|
40
|
+
private scopedPathForDisplay;
|
|
41
|
+
private getFileSuggestions;
|
|
42
|
+
private scoreEntry;
|
|
43
|
+
private getFuzzyFileSuggestions;
|
|
44
|
+
getForceFileSuggestions(lines: string[], cursorLine: number, cursorCol: number): {
|
|
45
|
+
items: AutocompleteItem[];
|
|
46
|
+
prefix: string;
|
|
47
|
+
} | null;
|
|
48
|
+
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=autocomplete.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"autocomplete.d.ts","sourceRoot":"","sources":["../src/autocomplete.ts"],"names":[],"mappings":"AAmJA,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IAGrB,sBAAsB,CAAC,CAAC,cAAc,EAAE,MAAM,GAAG,gBAAgB,EAAE,GAAG,IAAI,CAAC;CAC3E;AAED,MAAM,WAAW,oBAAoB;IAGpC,cAAc,CACb,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GACf;QACF,KAAK,EAAE,gBAAgB,EAAE,CAAC;QAC1B,MAAM,EAAE,MAAM,CAAC;KACf,GAAG,IAAI,CAAC;IAIT,eAAe,CACd,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,gBAAgB,EACtB,MAAM,EAAE,MAAM,GACZ;QACF,KAAK,EAAE,MAAM,EAAE,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,EAAE,MAAM,CAAC;KAClB,CAAC;CACF;AAGD,qBAAa,4BAA6B,YAAW,oBAAoB;IACxE,OAAO,CAAC,QAAQ,CAAsC;IACtD,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,MAAM,CAAgB;IAE9B,YACC,QAAQ,GAAE,CAAC,YAAY,GAAG,gBAAgB,CAAC,EAAO,EAClD,QAAQ,GAAE,MAAsB,EAChC,MAAM,GAAE,MAAM,GAAG,IAAW,EAK5B;IAED,cAAc,CACb,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GACf;QAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CA6FtD;IAED,eAAe,CACd,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,gBAAgB,EACtB,MAAM,EAAE,MAAM,GACZ;QAAE,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CA+E5D;IAGD,OAAO,CAAC,eAAe;IAiBvB,OAAO,CAAC,iBAAiB;IA8BzB,OAAO,CAAC,cAAc;IAWtB,OAAO,CAAC,uBAAuB;IA6B/B,OAAO,CAAC,oBAAoB;IAQ5B,OAAO,CAAC,kBAAkB;IAoI1B,OAAO,CAAC,UAAU;IAuBlB,OAAO,CAAC,uBAAuB;IAsD/B,uBAAuB,CACtB,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GACf;QAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAsBtD;IAGD,2BAA2B,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAU3F;CACD","sourcesContent":["import { spawnSync } from \"child_process\";\nimport { readdirSync, statSync } from \"fs\";\nimport { homedir } from \"os\";\nimport { basename, dirname, join } from \"path\";\nimport { fuzzyFilter } from \"./fuzzy.js\";\n\nconst PATH_DELIMITERS = new Set([\" \", \"\\t\", '\"', \"'\", \"=\"]);\n\nfunction findLastDelimiter(text: string): number {\n\tfor (let i = text.length - 1; i >= 0; i -= 1) {\n\t\tif (PATH_DELIMITERS.has(text[i] ?? \"\")) {\n\t\t\treturn i;\n\t\t}\n\t}\n\treturn -1;\n}\n\nfunction findUnclosedQuoteStart(text: string): number | null {\n\tlet inQuotes = false;\n\tlet quoteStart = -1;\n\n\tfor (let i = 0; i < text.length; i += 1) {\n\t\tif (text[i] === '\"') {\n\t\t\tinQuotes = !inQuotes;\n\t\t\tif (inQuotes) {\n\t\t\t\tquoteStart = i;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn inQuotes ? quoteStart : null;\n}\n\nfunction isTokenStart(text: string, index: number): boolean {\n\treturn index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? \"\");\n}\n\nfunction extractQuotedPrefix(text: string): string | null {\n\tconst quoteStart = findUnclosedQuoteStart(text);\n\tif (quoteStart === null) {\n\t\treturn null;\n\t}\n\n\tif (quoteStart > 0 && text[quoteStart - 1] === \"@\") {\n\t\tif (!isTokenStart(text, quoteStart - 1)) {\n\t\t\treturn null;\n\t\t}\n\t\treturn text.slice(quoteStart - 1);\n\t}\n\n\tif (!isTokenStart(text, quoteStart)) {\n\t\treturn null;\n\t}\n\n\treturn text.slice(quoteStart);\n}\n\nfunction parsePathPrefix(prefix: string): { rawPrefix: string; isAtPrefix: boolean; isQuotedPrefix: boolean } {\n\tif (prefix.startsWith('@\"')) {\n\t\treturn { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true };\n\t}\n\tif (prefix.startsWith('\"')) {\n\t\treturn { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true };\n\t}\n\tif (prefix.startsWith(\"@\")) {\n\t\treturn { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false };\n\t}\n\treturn { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false };\n}\n\nfunction buildCompletionValue(\n\tpath: string,\n\toptions: { isDirectory: boolean; isAtPrefix: boolean; isQuotedPrefix: boolean },\n): string {\n\tconst needsQuotes = options.isQuotedPrefix || path.includes(\" \");\n\tconst prefix = options.isAtPrefix ? \"@\" : \"\";\n\n\tif (!needsQuotes) {\n\t\treturn `${prefix}${path}`;\n\t}\n\n\tconst openQuote = `${prefix}\"`;\n\tconst closeQuote = '\"';\n\treturn `${openQuote}${path}${closeQuote}`;\n}\n\n// Use fd to walk directory tree (fast, respects .gitignore)\nfunction walkDirectoryWithFd(\n\tbaseDir: string,\n\tfdPath: string,\n\tquery: string,\n\tmaxResults: number,\n): Array<{ path: string; isDirectory: boolean }> {\n\tconst args = [\n\t\t\"--base-directory\",\n\t\tbaseDir,\n\t\t\"--max-results\",\n\t\tString(maxResults),\n\t\t\"--type\",\n\t\t\"f\",\n\t\t\"--type\",\n\t\t\"d\",\n\t\t\"--full-path\",\n\t\t\"--hidden\",\n\t\t\"--exclude\",\n\t\t\".git\",\n\t\t\"--exclude\",\n\t\t\".git/*\",\n\t\t\"--exclude\",\n\t\t\".git/**\",\n\t];\n\n\t// Add query as pattern if provided\n\tif (query) {\n\t\targs.push(query);\n\t}\n\n\tconst result = spawnSync(fdPath, args, {\n\t\tencoding: \"utf-8\",\n\t\tstdio: [\"pipe\", \"pipe\", \"pipe\"],\n\t\tmaxBuffer: 10 * 1024 * 1024,\n\t});\n\n\tif (result.status !== 0 || !result.stdout) {\n\t\treturn [];\n\t}\n\n\tconst lines = result.stdout.trim().split(\"\\n\").filter(Boolean);\n\tconst results: Array<{ path: string; isDirectory: boolean }> = [];\n\n\tfor (const line of lines) {\n\t\tconst normalizedPath = line.endsWith(\"/\") ? line.slice(0, -1) : line;\n\t\tif (normalizedPath === \".git\" || normalizedPath.startsWith(\".git/\") || normalizedPath.includes(\"/.git/\")) {\n\t\t\tcontinue;\n\t\t}\n\n\t\t// fd outputs directories with trailing /\n\t\tconst isDirectory = line.endsWith(\"/\");\n\t\tresults.push({\n\t\t\tpath: line,\n\t\t\tisDirectory,\n\t\t});\n\t}\n\n\treturn results;\n}\n\nexport interface AutocompleteItem {\n\tvalue: string;\n\tlabel: string;\n\tdescription?: string;\n}\n\nexport interface SlashCommand {\n\tname: string;\n\tdescription?: string;\n\t// Function to get argument completions for this command\n\t// Returns null if no argument completion is available\n\tgetArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;\n}\n\nexport interface AutocompleteProvider {\n\t// Get autocomplete suggestions for current text/cursor position\n\t// Returns null if no suggestions available\n\tgetSuggestions(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t): {\n\t\titems: AutocompleteItem[];\n\t\tprefix: string; // What we're matching against (e.g., \"/\" or \"src/\")\n\t} | null;\n\n\t// Apply the selected item\n\t// Returns the new text and cursor position\n\tapplyCompletion(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t\titem: AutocompleteItem,\n\t\tprefix: string,\n\t): {\n\t\tlines: string[];\n\t\tcursorLine: number;\n\t\tcursorCol: number;\n\t};\n}\n\n// Combined provider that handles both slash commands and file paths\nexport class CombinedAutocompleteProvider implements AutocompleteProvider {\n\tprivate commands: (SlashCommand | AutocompleteItem)[];\n\tprivate basePath: string;\n\tprivate fdPath: string | null;\n\n\tconstructor(\n\t\tcommands: (SlashCommand | AutocompleteItem)[] = [],\n\t\tbasePath: string = process.cwd(),\n\t\tfdPath: string | null = null,\n\t) {\n\t\tthis.commands = commands;\n\t\tthis.basePath = basePath;\n\t\tthis.fdPath = fdPath;\n\t}\n\n\tgetSuggestions(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t): { items: AutocompleteItem[]; prefix: string } | null {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\n\t\t// Check for @ file reference (fuzzy search) - must be after a delimiter or at start\n\t\tconst atPrefix = this.extractAtPrefix(textBeforeCursor);\n\t\tif (atPrefix) {\n\t\t\tconst { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);\n\t\t\tconst suggestions = this.getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix: isQuotedPrefix });\n\t\t\tif (suggestions.length === 0) return null;\n\n\t\t\treturn {\n\t\t\t\titems: suggestions,\n\t\t\t\tprefix: atPrefix,\n\t\t\t};\n\t\t}\n\n\t\t// Check for slash commands\n\t\tif (textBeforeCursor.startsWith(\"/\")) {\n\t\t\tconst spaceIndex = textBeforeCursor.indexOf(\" \");\n\n\t\t\tif (spaceIndex === -1) {\n\t\t\t\t// No space yet - complete command names with fuzzy matching\n\t\t\t\tconst prefix = textBeforeCursor.slice(1); // Remove the \"/\"\n\t\t\t\tconst commandItems = this.commands.map((cmd) => ({\n\t\t\t\t\tname: \"name\" in cmd ? cmd.name : cmd.value,\n\t\t\t\t\tlabel: \"name\" in cmd ? cmd.name : cmd.label,\n\t\t\t\t\tdescription: cmd.description,\n\t\t\t\t}));\n\n\t\t\t\tconst filtered = fuzzyFilter(commandItems, prefix, (item) => item.name).map((item) => ({\n\t\t\t\t\tvalue: item.name,\n\t\t\t\t\tlabel: item.label,\n\t\t\t\t\t...(item.description && { description: item.description }),\n\t\t\t\t}));\n\n\t\t\t\tif (filtered.length === 0) return null;\n\n\t\t\t\treturn {\n\t\t\t\t\titems: filtered,\n\t\t\t\t\tprefix: textBeforeCursor,\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\t// Space found - complete command arguments\n\t\t\t\tconst commandName = textBeforeCursor.slice(1, spaceIndex); // Command without \"/\"\n\t\t\t\tconst argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space\n\n\t\t\t\tconst command = this.commands.find((cmd) => {\n\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\treturn name === commandName;\n\t\t\t\t});\n\t\t\t\tif (!command || !(\"getArgumentCompletions\" in command) || !command.getArgumentCompletions) {\n\t\t\t\t\treturn null; // No argument completion for this command\n\t\t\t\t}\n\n\t\t\t\tconst argumentSuggestions = command.getArgumentCompletions(argumentText);\n\t\t\t\tif (!argumentSuggestions || argumentSuggestions.length === 0) {\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\n\t\t\t\treturn {\n\t\t\t\t\titems: argumentSuggestions,\n\t\t\t\t\tprefix: argumentText,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Check for file paths - triggered by Tab or if we detect a path pattern\n\t\tconst pathMatch = this.extractPathPrefix(textBeforeCursor, false);\n\n\t\tif (pathMatch !== null) {\n\t\t\tconst suggestions = this.getFileSuggestions(pathMatch);\n\t\t\tif (suggestions.length === 0) return null;\n\n\t\t\t// Check if we have an exact match that is a directory\n\t\t\t// In that case, we might want to return suggestions for the directory content instead\n\t\t\t// But only if the prefix ends with /\n\t\t\tif (suggestions.length === 1 && suggestions[0]?.value === pathMatch && !pathMatch.endsWith(\"/\")) {\n\t\t\t\t// Exact match found (e.g. user typed \"src\" and \"src/\" is the only match)\n\t\t\t\t// We still return it so user can select it and add /\n\t\t\t\treturn {\n\t\t\t\t\titems: suggestions,\n\t\t\t\t\tprefix: pathMatch,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\titems: suggestions,\n\t\t\t\tprefix: pathMatch,\n\t\t\t};\n\t\t}\n\n\t\treturn null;\n\t}\n\n\tapplyCompletion(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t\titem: AutocompleteItem,\n\t\tprefix: string,\n\t): { lines: string[]; cursorLine: number; cursorCol: number } {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst beforePrefix = currentLine.slice(0, cursorCol - prefix.length);\n\t\tconst afterCursor = currentLine.slice(cursorCol);\n\t\tconst isQuotedPrefix = prefix.startsWith('\"') || prefix.startsWith('@\"');\n\t\tconst hasLeadingQuoteAfterCursor = afterCursor.startsWith('\"');\n\t\tconst hasTrailingQuoteInItem = item.value.endsWith('\"');\n\t\tconst adjustedAfterCursor =\n\t\t\tisQuotedPrefix && hasTrailingQuoteInItem && hasLeadingQuoteAfterCursor ? afterCursor.slice(1) : afterCursor;\n\n\t\t// Check if we're completing a slash command (prefix starts with \"/\" but NOT a file path)\n\t\t// Slash commands are at the start of the line and don't contain path separators after the first /\n\t\tconst isSlashCommand = prefix.startsWith(\"/\") && beforePrefix.trim() === \"\" && !prefix.slice(1).includes(\"/\");\n\t\tif (isSlashCommand) {\n\t\t\t// This is a command name completion\n\t\t\tconst newLine = `${beforePrefix}/${item.value} ${adjustedAfterCursor}`;\n\t\t\tconst newLines = [...lines];\n\t\t\tnewLines[cursorLine] = newLine;\n\n\t\t\treturn {\n\t\t\t\tlines: newLines,\n\t\t\t\tcursorLine,\n\t\t\t\tcursorCol: beforePrefix.length + item.value.length + 2, // +2 for \"/\" and space\n\t\t\t};\n\t\t}\n\n\t\t// Check if we're completing a file attachment (prefix starts with \"@\")\n\t\tif (prefix.startsWith(\"@\")) {\n\t\t\t// This is a file attachment completion\n\t\t\t// Don't add space after directories so user can continue autocompleting\n\t\t\tconst isDirectory = item.label.endsWith(\"/\");\n\t\t\tconst suffix = isDirectory ? \"\" : \" \";\n\t\t\tconst newLine = `${beforePrefix + item.value}${suffix}${adjustedAfterCursor}`;\n\t\t\tconst newLines = [...lines];\n\t\t\tnewLines[cursorLine] = newLine;\n\n\t\t\tconst hasTrailingQuote = item.value.endsWith('\"');\n\t\t\tconst cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;\n\n\t\t\treturn {\n\t\t\t\tlines: newLines,\n\t\t\t\tcursorLine,\n\t\t\t\tcursorCol: beforePrefix.length + cursorOffset + suffix.length,\n\t\t\t};\n\t\t}\n\n\t\t// Check if we're in a slash command context (beforePrefix contains \"/command \")\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\t\tif (textBeforeCursor.includes(\"/\") && textBeforeCursor.includes(\" \")) {\n\t\t\t// This is likely a command argument completion\n\t\t\tconst newLine = beforePrefix + item.value + adjustedAfterCursor;\n\t\t\tconst newLines = [...lines];\n\t\t\tnewLines[cursorLine] = newLine;\n\n\t\t\tconst isDirectory = item.label.endsWith(\"/\");\n\t\t\tconst hasTrailingQuote = item.value.endsWith('\"');\n\t\t\tconst cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;\n\n\t\t\treturn {\n\t\t\t\tlines: newLines,\n\t\t\t\tcursorLine,\n\t\t\t\tcursorCol: beforePrefix.length + cursorOffset,\n\t\t\t};\n\t\t}\n\n\t\t// For file paths, complete the path\n\t\tconst newLine = beforePrefix + item.value + adjustedAfterCursor;\n\t\tconst newLines = [...lines];\n\t\tnewLines[cursorLine] = newLine;\n\n\t\tconst isDirectory = item.label.endsWith(\"/\");\n\t\tconst hasTrailingQuote = item.value.endsWith('\"');\n\t\tconst cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;\n\n\t\treturn {\n\t\t\tlines: newLines,\n\t\t\tcursorLine,\n\t\t\tcursorCol: beforePrefix.length + cursorOffset,\n\t\t};\n\t}\n\n\t// Extract @ prefix for fuzzy file suggestions\n\tprivate extractAtPrefix(text: string): string | null {\n\t\tconst quotedPrefix = extractQuotedPrefix(text);\n\t\tif (quotedPrefix?.startsWith('@\"')) {\n\t\t\treturn quotedPrefix;\n\t\t}\n\n\t\tconst lastDelimiterIndex = findLastDelimiter(text);\n\t\tconst tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;\n\n\t\tif (text[tokenStart] === \"@\") {\n\t\t\treturn text.slice(tokenStart);\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t// Extract a path-like prefix from the text before cursor\n\tprivate extractPathPrefix(text: string, forceExtract: boolean = false): string | null {\n\t\tconst quotedPrefix = extractQuotedPrefix(text);\n\t\tif (quotedPrefix) {\n\t\t\treturn quotedPrefix;\n\t\t}\n\n\t\tconst lastDelimiterIndex = findLastDelimiter(text);\n\t\tconst pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);\n\n\t\t// For forced extraction (Tab key), always return something\n\t\tif (forceExtract) {\n\t\t\treturn pathPrefix;\n\t\t}\n\n\t\t// For natural triggers, return if it looks like a path, ends with /, starts with ~/, .\n\t\t// Only return empty string if the text looks like it's starting a path context\n\t\tif (pathPrefix.includes(\"/\") || pathPrefix.startsWith(\".\") || pathPrefix.startsWith(\"~/\")) {\n\t\t\treturn pathPrefix;\n\t\t}\n\n\t\t// Return empty string only after a space (not for completely empty text)\n\t\t// Empty text should not trigger file suggestions - that's for forced Tab completion\n\t\tif (pathPrefix === \"\" && text.endsWith(\" \")) {\n\t\t\treturn pathPrefix;\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t// Expand home directory (~/) to actual home path\n\tprivate expandHomePath(path: string): string {\n\t\tif (path.startsWith(\"~/\")) {\n\t\t\tconst expandedPath = join(homedir(), path.slice(2));\n\t\t\t// Preserve trailing slash if original path had one\n\t\t\treturn path.endsWith(\"/\") && !expandedPath.endsWith(\"/\") ? `${expandedPath}/` : expandedPath;\n\t\t} else if (path === \"~\") {\n\t\t\treturn homedir();\n\t\t}\n\t\treturn path;\n\t}\n\n\tprivate resolveScopedFuzzyQuery(rawQuery: string): { baseDir: string; query: string; displayBase: string } | null {\n\t\tconst slashIndex = rawQuery.lastIndexOf(\"/\");\n\t\tif (slashIndex === -1) {\n\t\t\treturn null;\n\t\t}\n\n\t\tconst displayBase = rawQuery.slice(0, slashIndex + 1);\n\t\tconst query = rawQuery.slice(slashIndex + 1);\n\n\t\tlet baseDir: string;\n\t\tif (displayBase.startsWith(\"~/\")) {\n\t\t\tbaseDir = this.expandHomePath(displayBase);\n\t\t} else if (displayBase.startsWith(\"/\")) {\n\t\t\tbaseDir = displayBase;\n\t\t} else {\n\t\t\tbaseDir = join(this.basePath, displayBase);\n\t\t}\n\n\t\ttry {\n\t\t\tif (!statSync(baseDir).isDirectory()) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\n\t\treturn { baseDir, query, displayBase };\n\t}\n\n\tprivate scopedPathForDisplay(displayBase: string, relativePath: string): string {\n\t\tif (displayBase === \"/\") {\n\t\t\treturn `/${relativePath}`;\n\t\t}\n\t\treturn `${displayBase}${relativePath}`;\n\t}\n\n\t// Get file/directory suggestions for a given path prefix\n\tprivate getFileSuggestions(prefix: string): AutocompleteItem[] {\n\t\ttry {\n\t\t\tlet searchDir: string;\n\t\t\tlet searchPrefix: string;\n\t\t\tconst { rawPrefix, isAtPrefix, isQuotedPrefix } = parsePathPrefix(prefix);\n\t\t\tlet expandedPrefix = rawPrefix;\n\n\t\t\t// Handle home directory expansion\n\t\t\tif (expandedPrefix.startsWith(\"~\")) {\n\t\t\t\texpandedPrefix = this.expandHomePath(expandedPrefix);\n\t\t\t}\n\n\t\t\tconst isRootPrefix =\n\t\t\t\trawPrefix === \"\" ||\n\t\t\t\trawPrefix === \"./\" ||\n\t\t\t\trawPrefix === \"../\" ||\n\t\t\t\trawPrefix === \"~\" ||\n\t\t\t\trawPrefix === \"~/\" ||\n\t\t\t\trawPrefix === \"/\" ||\n\t\t\t\t(isAtPrefix && rawPrefix === \"\");\n\n\t\t\tif (isRootPrefix) {\n\t\t\t\t// Complete from specified position\n\t\t\t\tif (rawPrefix.startsWith(\"~\") || expandedPrefix.startsWith(\"/\")) {\n\t\t\t\t\tsearchDir = expandedPrefix;\n\t\t\t\t} else {\n\t\t\t\t\tsearchDir = join(this.basePath, expandedPrefix);\n\t\t\t\t}\n\t\t\t\tsearchPrefix = \"\";\n\t\t\t} else if (rawPrefix.endsWith(\"/\")) {\n\t\t\t\t// If prefix ends with /, show contents of that directory\n\t\t\t\tif (rawPrefix.startsWith(\"~\") || expandedPrefix.startsWith(\"/\")) {\n\t\t\t\t\tsearchDir = expandedPrefix;\n\t\t\t\t} else {\n\t\t\t\t\tsearchDir = join(this.basePath, expandedPrefix);\n\t\t\t\t}\n\t\t\t\tsearchPrefix = \"\";\n\t\t\t} else {\n\t\t\t\t// Split into directory and file prefix\n\t\t\t\tconst dir = dirname(expandedPrefix);\n\t\t\t\tconst file = basename(expandedPrefix);\n\t\t\t\tif (rawPrefix.startsWith(\"~\") || expandedPrefix.startsWith(\"/\")) {\n\t\t\t\t\tsearchDir = dir;\n\t\t\t\t} else {\n\t\t\t\t\tsearchDir = join(this.basePath, dir);\n\t\t\t\t}\n\t\t\t\tsearchPrefix = file;\n\t\t\t}\n\n\t\t\tconst entries = readdirSync(searchDir, { withFileTypes: true });\n\t\t\tconst suggestions: AutocompleteItem[] = [];\n\n\t\t\tfor (const entry of entries) {\n\t\t\t\tif (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// Check if entry is a directory (or a symlink pointing to a directory)\n\t\t\t\tlet isDirectory = entry.isDirectory();\n\t\t\t\tif (!isDirectory && entry.isSymbolicLink()) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst fullPath = join(searchDir, entry.name);\n\t\t\t\t\t\tisDirectory = statSync(fullPath).isDirectory();\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// Broken symlink or permission error - treat as file\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlet relativePath: string;\n\t\t\t\tconst name = entry.name;\n\t\t\t\tconst displayPrefix = rawPrefix;\n\n\t\t\t\tif (displayPrefix.endsWith(\"/\")) {\n\t\t\t\t\t// If prefix ends with /, append entry to the prefix\n\t\t\t\t\trelativePath = displayPrefix + name;\n\t\t\t\t} else if (displayPrefix.includes(\"/\")) {\n\t\t\t\t\t// Preserve ~/ format for home directory paths\n\t\t\t\t\tif (displayPrefix.startsWith(\"~/\")) {\n\t\t\t\t\t\tconst homeRelativeDir = displayPrefix.slice(2); // Remove ~/\n\t\t\t\t\t\tconst dir = dirname(homeRelativeDir);\n\t\t\t\t\t\trelativePath = `~/${dir === \".\" ? name : join(dir, name)}`;\n\t\t\t\t\t} else if (displayPrefix.startsWith(\"/\")) {\n\t\t\t\t\t\t// Absolute path - construct properly\n\t\t\t\t\t\tconst dir = dirname(displayPrefix);\n\t\t\t\t\t\tif (dir === \"/\") {\n\t\t\t\t\t\t\trelativePath = `/${name}`;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\trelativePath = `${dir}/${name}`;\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\trelativePath = join(dirname(displayPrefix), name);\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// For standalone entries, preserve ~/ if original prefix was ~/\n\t\t\t\t\tif (displayPrefix.startsWith(\"~\")) {\n\t\t\t\t\t\trelativePath = `~/${name}`;\n\t\t\t\t\t} else {\n\t\t\t\t\t\trelativePath = name;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst pathValue = isDirectory ? `${relativePath}/` : relativePath;\n\t\t\t\tconst value = buildCompletionValue(pathValue, {\n\t\t\t\t\tisDirectory,\n\t\t\t\t\tisAtPrefix,\n\t\t\t\t\tisQuotedPrefix,\n\t\t\t\t});\n\n\t\t\t\tsuggestions.push({\n\t\t\t\t\tvalue,\n\t\t\t\t\tlabel: name + (isDirectory ? \"/\" : \"\"),\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t// Sort directories first, then alphabetically\n\t\t\tsuggestions.sort((a, b) => {\n\t\t\t\tconst aIsDir = a.value.endsWith(\"/\");\n\t\t\t\tconst bIsDir = b.value.endsWith(\"/\");\n\t\t\t\tif (aIsDir && !bIsDir) return -1;\n\t\t\t\tif (!aIsDir && bIsDir) return 1;\n\t\t\t\treturn a.label.localeCompare(b.label);\n\t\t\t});\n\n\t\t\treturn suggestions;\n\t\t} catch (_e) {\n\t\t\t// Directory doesn't exist or not accessible\n\t\t\treturn [];\n\t\t}\n\t}\n\n\t// Score an entry against the query (higher = better match)\n\t// isDirectory adds bonus to prioritize folders\n\tprivate scoreEntry(filePath: string, query: string, isDirectory: boolean): number {\n\t\tconst fileName = basename(filePath);\n\t\tconst lowerFileName = fileName.toLowerCase();\n\t\tconst lowerQuery = query.toLowerCase();\n\n\t\tlet score = 0;\n\n\t\t// Exact filename match (highest)\n\t\tif (lowerFileName === lowerQuery) score = 100;\n\t\t// Filename starts with query\n\t\telse if (lowerFileName.startsWith(lowerQuery)) score = 80;\n\t\t// Substring match in filename\n\t\telse if (lowerFileName.includes(lowerQuery)) score = 50;\n\t\t// Substring match in full path\n\t\telse if (filePath.toLowerCase().includes(lowerQuery)) score = 30;\n\n\t\t// Directories get a bonus to appear first\n\t\tif (isDirectory && score > 0) score += 10;\n\n\t\treturn score;\n\t}\n\n\t// Fuzzy file search using fd (fast, respects .gitignore)\n\tprivate getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): AutocompleteItem[] {\n\t\tif (!this.fdPath) {\n\t\t\t// fd not available, return empty results\n\t\t\treturn [];\n\t\t}\n\n\t\ttry {\n\t\t\tconst scopedQuery = this.resolveScopedFuzzyQuery(query);\n\t\t\tconst fdBaseDir = scopedQuery?.baseDir ?? this.basePath;\n\t\t\tconst fdQuery = scopedQuery?.query ?? query;\n\t\t\tconst entries = walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100);\n\n\t\t\t// Score entries\n\t\t\tconst scoredEntries = entries\n\t\t\t\t.map((entry) => ({\n\t\t\t\t\t...entry,\n\t\t\t\t\tscore: fdQuery ? this.scoreEntry(entry.path, fdQuery, entry.isDirectory) : 1,\n\t\t\t\t}))\n\t\t\t\t.filter((entry) => entry.score > 0);\n\n\t\t\t// Sort by score (descending) and take top 20\n\t\t\tscoredEntries.sort((a, b) => b.score - a.score);\n\t\t\tconst topEntries = scoredEntries.slice(0, 20);\n\n\t\t\t// Build suggestions\n\t\t\tconst suggestions: AutocompleteItem[] = [];\n\t\t\tfor (const { path: entryPath, isDirectory } of topEntries) {\n\t\t\t\t// fd already includes trailing / for directories\n\t\t\t\tconst pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;\n\t\t\t\tconst displayPath = scopedQuery\n\t\t\t\t\t? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash)\n\t\t\t\t\t: pathWithoutSlash;\n\t\t\t\tconst entryName = basename(pathWithoutSlash);\n\t\t\t\tconst completionPath = isDirectory ? `${displayPath}/` : displayPath;\n\t\t\t\tconst value = buildCompletionValue(completionPath, {\n\t\t\t\t\tisDirectory,\n\t\t\t\t\tisAtPrefix: true,\n\t\t\t\t\tisQuotedPrefix: options.isQuotedPrefix,\n\t\t\t\t});\n\n\t\t\t\tsuggestions.push({\n\t\t\t\t\tvalue,\n\t\t\t\t\tlabel: entryName + (isDirectory ? \"/\" : \"\"),\n\t\t\t\t\tdescription: displayPath,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\treturn suggestions;\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t}\n\n\t// Force file completion (called on Tab key) - always returns suggestions\n\tgetForceFileSuggestions(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t): { items: AutocompleteItem[]; prefix: string } | null {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\n\t\t// Don't trigger if we're typing a slash command at the start of the line\n\t\tif (textBeforeCursor.trim().startsWith(\"/\") && !textBeforeCursor.trim().includes(\" \")) {\n\t\t\treturn null;\n\t\t}\n\n\t\t// Force extract path prefix - this will always return something\n\t\tconst pathMatch = this.extractPathPrefix(textBeforeCursor, true);\n\t\tif (pathMatch !== null) {\n\t\t\tconst suggestions = this.getFileSuggestions(pathMatch);\n\t\t\tif (suggestions.length === 0) return null;\n\n\t\t\treturn {\n\t\t\t\titems: suggestions,\n\t\t\t\tprefix: pathMatch,\n\t\t\t};\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t// Check if we should trigger file completion (called on Tab key)\n\tshouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\n\t\t// Don't trigger if we're typing a slash command at the start of the line\n\t\tif (textBeforeCursor.trim().startsWith(\"/\") && !textBeforeCursor.trim().includes(\" \")) {\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n}\n"]}
|