omk-tui 1.2.0 → 1.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +14 -0
  3. package/dist/ansi-codes.d.ts +16 -0
  4. package/dist/ansi-codes.d.ts.map +1 -0
  5. package/dist/ansi-codes.js +62 -0
  6. package/dist/ansi-codes.js.map +1 -0
  7. package/dist/autocomplete-path-prefix.d.ts +14 -0
  8. package/dist/autocomplete-path-prefix.d.ts.map +1 -0
  9. package/dist/autocomplete-path-prefix.js +50 -0
  10. package/dist/autocomplete-path-prefix.js.map +1 -0
  11. package/dist/autocomplete.d.ts +1 -0
  12. package/dist/autocomplete.d.ts.map +1 -1
  13. package/dist/autocomplete.js +38 -66
  14. package/dist/autocomplete.js.map +1 -1
  15. package/dist/components/editor-scroll-border.d.ts +3 -0
  16. package/dist/components/editor-scroll-border.d.ts.map +1 -0
  17. package/dist/components/editor-scroll-border.js +13 -0
  18. package/dist/components/editor-scroll-border.js.map +1 -0
  19. package/dist/components/editor.d.ts.map +1 -1
  20. package/dist/components/editor.js +17 -23
  21. package/dist/components/editor.js.map +1 -1
  22. package/dist/components/image.d.ts.map +1 -1
  23. package/dist/components/image.js +3 -2
  24. package/dist/components/image.js.map +1 -1
  25. package/dist/components/markdown-tokenizer.d.ts +6 -0
  26. package/dist/components/markdown-tokenizer.d.ts.map +1 -0
  27. package/dist/components/markdown-tokenizer.js +30 -0
  28. package/dist/components/markdown-tokenizer.js.map +1 -0
  29. package/dist/components/markdown.d.ts.map +1 -1
  30. package/dist/components/markdown.js +7 -17
  31. package/dist/components/markdown.js.map +1 -1
  32. package/dist/image-fallback.d.ts +5 -0
  33. package/dist/image-fallback.d.ts.map +1 -0
  34. package/dist/image-fallback.js +17 -0
  35. package/dist/image-fallback.js.map +1 -0
  36. package/dist/image-path.d.ts +3 -0
  37. package/dist/image-path.d.ts.map +1 -0
  38. package/dist/image-path.js +10 -0
  39. package/dist/image-path.js.map +1 -0
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/key-character.d.ts +3 -0
  44. package/dist/key-character.d.ts.map +1 -0
  45. package/dist/key-character.js +11 -0
  46. package/dist/key-character.js.map +1 -0
  47. package/dist/keys.d.ts.map +1 -1
  48. package/dist/keys.js +19 -32
  49. package/dist/keys.js.map +1 -1
  50. package/dist/terminal-final-frame.d.ts +3 -0
  51. package/dist/terminal-final-frame.d.ts.map +1 -0
  52. package/dist/terminal-final-frame.js +15 -0
  53. package/dist/terminal-final-frame.js.map +1 -0
  54. package/dist/terminal-image.d.ts +5 -0
  55. package/dist/terminal-image.d.ts.map +1 -1
  56. package/dist/terminal-image.js +12 -12
  57. package/dist/terminal-image.js.map +1 -1
  58. package/dist/terminal-output.d.ts +26 -0
  59. package/dist/terminal-output.d.ts.map +1 -0
  60. package/dist/terminal-output.js +82 -0
  61. package/dist/terminal-output.js.map +1 -0
  62. package/dist/terminal.d.ts +7 -1
  63. package/dist/terminal.d.ts.map +1 -1
  64. package/dist/terminal.js +30 -52
  65. package/dist/terminal.js.map +1 -1
  66. package/dist/tui.d.ts.map +1 -1
  67. package/dist/tui.js +2 -14
  68. package/dist/tui.js.map +1 -1
  69. package/dist/utils.d.ts +1 -4
  70. package/dist/utils.d.ts.map +1 -1
  71. package/dist/utils.js +28 -71
  72. package/dist/utils.js.map +1 -1
  73. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.2] - 2026-09-23
4
+
5
+ ### Changed
6
+
7
+ - Lockstep version alignment with the OMK packages. This cycle's changes live in `open-multi-agent-kit`.
8
+
9
+ ## [1.2.1] - 2026-09-23
10
+
11
+ ### Added
12
+
13
+ - `TerminalOutput` separates output observation from the terminal implementation: writes, resizes and capability queries are observed through one object, and interactive diagnostics consume the same observation.
14
+
15
+ ### Fixed
16
+
17
+ - Zed terminal capabilities are detected (#8828).
18
+ - Autocomplete orders nested results (#8669) and re-queries the picker when the cursor moves; the hardware cursor marker is kept during slash-command autocomplete.
19
+ - Truncated OSC 8 links are closed (#7657).
20
+ - The iTerm image payload size is emitted for the xterm.js image addon (#7612), and image fallback paths are shortened with clamped widths (#7262).
21
+ - The inverted cursor is cleared on exit so no dual cursor appears (#6790).
22
+ - CRLF and CR line endings are handled (#6764) and tabs are normalized in terminal output (#6697).
23
+ - Legacy alt-prefixed symbols parse (#6523) and legacy terminals get shift+letter shortcut support.
24
+ - Streaming code fence rendering is stabilized (#5846), and loose lists separate list items with blank lines.
25
+ - Auxiliary modules (ANSI codes, key characters, image fallback, markdown tokenizer, autocomplete path prefix) are split out of the large modules, preserving the exit frame.
26
+
3
27
  ## [1.2.0] - 2026-09-20
4
28
 
5
29
  ## [1.0.0] - 2026-09-18
package/README.md CHANGED
@@ -622,6 +622,19 @@ interface Terminal {
622
622
  - `ProcessTerminal` - Uses `process.stdin/stdout`
623
623
  - `VirtualTerminal` - For testing (uses `@xterm/headless`)
624
624
 
625
+ ### Output observations
626
+
627
+ `ProcessTerminal.getOutputStats()` reports `writeCalls`, `submittedBytes`,
628
+ `writeFalseCount`, `drainCount`, `errorCount`, `peakWritableLength`,
629
+ `writableLength`, and `backpressured`. The optional `Terminal.getOutputStats`
630
+ method preserves compatibility with custom terminals. The snapshot contains no
631
+ raw output. Bytes are offered bytes, not proof that a terminal displayed them.
632
+
633
+ The observer covers paint and control sequences, does not retry `write(false)`,
634
+ and leaves normal stream errors visible. It does not implement flow control or
635
+ bound Node's output queue. `ProcessTerminal` optionally accepts a Writable for
636
+ controlled output tests; stdin and terminal dimensions still belong to the process.
637
+
625
638
  ## Utilities
626
639
 
627
640
  ```typescript
@@ -765,6 +778,7 @@ See `test/chat-simple.ts` for a complete chat interface example with:
765
778
  - Spacers between messages
766
779
 
767
780
  Run it:
781
+
768
782
  ```bash
769
783
  npx tsx test/chat-simple.ts
770
784
  ```
@@ -0,0 +1,16 @@
1
+ /** Extract CSI, OSC and APC sequences while preserving their original terminators. */
2
+ export declare function extractAnsiCode(str: string, pos: number): {
3
+ code: string;
4
+ length: number;
5
+ } | null;
6
+ export type Osc8Terminator = "\x07" | "\x1b\\";
7
+ export interface ActiveHyperlink {
8
+ params: string;
9
+ url: string;
10
+ terminator: Osc8Terminator;
11
+ }
12
+ export declare function parseOsc8Hyperlink(ansiCode: string): ActiveHyperlink | null | undefined;
13
+ export declare function formatOsc8Hyperlink(hyperlink: ActiveHyperlink): string;
14
+ export declare function formatOsc8Close(terminator: Osc8Terminator): string;
15
+ export declare function getActiveOsc8Close(prefix: string): string;
16
+ //# sourceMappingURL=ansi-codes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ansi-codes.d.ts","sourceRoot":"","sources":["../src/ansi-codes.ts"],"names":[],"mappings":"AAAA,sFAAsF;AACtF,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAkBjG;AACD,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,QAAQ,CAAC;AAC/C,MAAM,WAAW,eAAe;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,cAAc,CAAC;CAC3B;AACD,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,GAAG,SAAS,CAUvF;AACD,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,eAAe,GAAG,MAAM,CAEtE;AACD,wBAAgB,eAAe,CAAC,UAAU,EAAE,cAAc,GAAG,MAAM,CAElE;AACD,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAYzD","sourcesContent":["/** Extract CSI, OSC and APC sequences while preserving their original terminators. */\nexport function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {\n\tif (pos >= str.length || str[pos] !== \"\\x1b\") return null;\n\tconst next = str[pos + 1];\n\tif (next === \"[\") {\n\t\tlet j = pos + 2;\n\t\twhile (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++;\n\t\tif (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos };\n\t\treturn null;\n\t}\n\tif (next === \"]\" || next === \"_\") {\n\t\tlet j = pos + 2;\n\t\twhile (j < str.length) {\n\t\t\tif (str[j] === \"\\x07\") return { code: str.substring(pos, j + 1), length: j + 1 - pos };\n\t\t\tif (str[j] === \"\\x1b\" && str[j + 1] === \"\\\\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos };\n\t\t\tj++;\n\t\t}\n\t}\n\treturn null;\n}\nexport type Osc8Terminator = \"\\x07\" | \"\\x1b\\\\\";\nexport interface ActiveHyperlink {\n\tparams: string;\n\turl: string;\n\tterminator: Osc8Terminator;\n}\nexport function parseOsc8Hyperlink(ansiCode: string): ActiveHyperlink | null | undefined {\n\tif (!ansiCode.startsWith(\"\\x1b]8;\")) return undefined;\n\tconst terminator: Osc8Terminator = ansiCode.endsWith(\"\\x07\") ? \"\\x07\" : \"\\x1b\\\\\";\n\tconst body = ansiCode.slice(4, terminator === \"\\x07\" ? -1 : -2);\n\tconst separatorIndex = body.indexOf(\";\");\n\tif (separatorIndex === -1) return undefined;\n\tconst params = body.slice(0, separatorIndex);\n\tconst url = body.slice(separatorIndex + 1);\n\tif (!url) return null;\n\treturn { params, url, terminator };\n}\nexport function formatOsc8Hyperlink(hyperlink: ActiveHyperlink): string {\n\treturn `\\x1b]8;${hyperlink.params};${hyperlink.url}${hyperlink.terminator}`;\n}\nexport function formatOsc8Close(terminator: Osc8Terminator): string {\n\treturn `\\x1b]8;;${terminator}`;\n}\nexport function getActiveOsc8Close(prefix: string): string {\n\tlet activeHyperlink: ActiveHyperlink | null = null;\n\tlet i = 0;\n\twhile (i < prefix.length) {\n\t\tconst ansi = extractAnsiCode(prefix, i);\n\t\tif (ansi) {\n\t\t\tconst hyperlink = parseOsc8Hyperlink(ansi.code);\n\t\t\tif (hyperlink !== undefined) activeHyperlink = hyperlink;\n\t\t\ti += ansi.length;\n\t\t} else i++;\n\t}\n\treturn activeHyperlink ? formatOsc8Close(activeHyperlink.terminator) : \"\";\n}\n"]}
@@ -0,0 +1,62 @@
1
+ /** Extract CSI, OSC and APC sequences while preserving their original terminators. */
2
+ export function extractAnsiCode(str, pos) {
3
+ if (pos >= str.length || str[pos] !== "\x1b")
4
+ return null;
5
+ const next = str[pos + 1];
6
+ if (next === "[") {
7
+ let j = pos + 2;
8
+ while (j < str.length && !/[mGKHJ]/.test(str[j]))
9
+ j++;
10
+ if (j < str.length)
11
+ return { code: str.substring(pos, j + 1), length: j + 1 - pos };
12
+ return null;
13
+ }
14
+ if (next === "]" || next === "_") {
15
+ let j = pos + 2;
16
+ while (j < str.length) {
17
+ if (str[j] === "\x07")
18
+ return { code: str.substring(pos, j + 1), length: j + 1 - pos };
19
+ if (str[j] === "\x1b" && str[j + 1] === "\\")
20
+ return { code: str.substring(pos, j + 2), length: j + 2 - pos };
21
+ j++;
22
+ }
23
+ }
24
+ return null;
25
+ }
26
+ export function parseOsc8Hyperlink(ansiCode) {
27
+ if (!ansiCode.startsWith("\x1b]8;"))
28
+ return undefined;
29
+ const terminator = ansiCode.endsWith("\x07") ? "\x07" : "\x1b\\";
30
+ const body = ansiCode.slice(4, terminator === "\x07" ? -1 : -2);
31
+ const separatorIndex = body.indexOf(";");
32
+ if (separatorIndex === -1)
33
+ return undefined;
34
+ const params = body.slice(0, separatorIndex);
35
+ const url = body.slice(separatorIndex + 1);
36
+ if (!url)
37
+ return null;
38
+ return { params, url, terminator };
39
+ }
40
+ export function formatOsc8Hyperlink(hyperlink) {
41
+ return `\x1b]8;${hyperlink.params};${hyperlink.url}${hyperlink.terminator}`;
42
+ }
43
+ export function formatOsc8Close(terminator) {
44
+ return `\x1b]8;;${terminator}`;
45
+ }
46
+ export function getActiveOsc8Close(prefix) {
47
+ let activeHyperlink = null;
48
+ let i = 0;
49
+ while (i < prefix.length) {
50
+ const ansi = extractAnsiCode(prefix, i);
51
+ if (ansi) {
52
+ const hyperlink = parseOsc8Hyperlink(ansi.code);
53
+ if (hyperlink !== undefined)
54
+ activeHyperlink = hyperlink;
55
+ i += ansi.length;
56
+ }
57
+ else
58
+ i++;
59
+ }
60
+ return activeHyperlink ? formatOsc8Close(activeHyperlink.terminator) : "";
61
+ }
62
+ //# sourceMappingURL=ansi-codes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ansi-codes.js","sourceRoot":"","sources":["../src/ansi-codes.ts"],"names":[],"mappings":"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","sourcesContent":["/** Extract CSI, OSC and APC sequences while preserving their original terminators. */\nexport function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {\n\tif (pos >= str.length || str[pos] !== \"\\x1b\") return null;\n\tconst next = str[pos + 1];\n\tif (next === \"[\") {\n\t\tlet j = pos + 2;\n\t\twhile (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++;\n\t\tif (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos };\n\t\treturn null;\n\t}\n\tif (next === \"]\" || next === \"_\") {\n\t\tlet j = pos + 2;\n\t\twhile (j < str.length) {\n\t\t\tif (str[j] === \"\\x07\") return { code: str.substring(pos, j + 1), length: j + 1 - pos };\n\t\t\tif (str[j] === \"\\x1b\" && str[j + 1] === \"\\\\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos };\n\t\t\tj++;\n\t\t}\n\t}\n\treturn null;\n}\nexport type Osc8Terminator = \"\\x07\" | \"\\x1b\\\\\";\nexport interface ActiveHyperlink {\n\tparams: string;\n\turl: string;\n\tterminator: Osc8Terminator;\n}\nexport function parseOsc8Hyperlink(ansiCode: string): ActiveHyperlink | null | undefined {\n\tif (!ansiCode.startsWith(\"\\x1b]8;\")) return undefined;\n\tconst terminator: Osc8Terminator = ansiCode.endsWith(\"\\x07\") ? \"\\x07\" : \"\\x1b\\\\\";\n\tconst body = ansiCode.slice(4, terminator === \"\\x07\" ? -1 : -2);\n\tconst separatorIndex = body.indexOf(\";\");\n\tif (separatorIndex === -1) return undefined;\n\tconst params = body.slice(0, separatorIndex);\n\tconst url = body.slice(separatorIndex + 1);\n\tif (!url) return null;\n\treturn { params, url, terminator };\n}\nexport function formatOsc8Hyperlink(hyperlink: ActiveHyperlink): string {\n\treturn `\\x1b]8;${hyperlink.params};${hyperlink.url}${hyperlink.terminator}`;\n}\nexport function formatOsc8Close(terminator: Osc8Terminator): string {\n\treturn `\\x1b]8;;${terminator}`;\n}\nexport function getActiveOsc8Close(prefix: string): string {\n\tlet activeHyperlink: ActiveHyperlink | null = null;\n\tlet i = 0;\n\twhile (i < prefix.length) {\n\t\tconst ansi = extractAnsiCode(prefix, i);\n\t\tif (ansi) {\n\t\t\tconst hyperlink = parseOsc8Hyperlink(ansi.code);\n\t\t\tif (hyperlink !== undefined) activeHyperlink = hyperlink;\n\t\t\ti += ansi.length;\n\t\t} else i++;\n\t}\n\treturn activeHyperlink ? formatOsc8Close(activeHyperlink.terminator) : \"\";\n}\n"]}
@@ -0,0 +1,14 @@
1
+ export declare const PATH_DELIMITERS: Set<string>;
2
+ export declare function findLastDelimiter(text: string): number;
3
+ export declare function extractQuotedPrefix(text: string): string | null;
4
+ export declare function parsePathPrefix(prefix: string): {
5
+ rawPrefix: string;
6
+ isAtPrefix: boolean;
7
+ isQuotedPrefix: boolean;
8
+ };
9
+ export declare function buildCompletionValue(path: string, options: {
10
+ isDirectory: boolean;
11
+ isAtPrefix: boolean;
12
+ isQuotedPrefix: boolean;
13
+ }): string;
14
+ //# sourceMappingURL=autocomplete-path-prefix.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"autocomplete-path-prefix.d.ts","sourceRoot":"","sources":["../src/autocomplete-path-prefix.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,eAAe,aAAsC,CAAC;AAEnE,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGtD;AAeD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAS/D;AACD,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAC;IAAC,cAAc,EAAE,OAAO,CAAA;CAAE,CAKnH;AACD,wBAAgB,oBAAoB,CACnC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE;IAAE,WAAW,EAAE,OAAO,CAAC;IAAC,UAAU,EAAE,OAAO,CAAC;IAAC,cAAc,EAAE,OAAO,CAAA;CAAE,GAC7E,MAAM,CAIR","sourcesContent":["export const PATH_DELIMITERS = new Set([\" \", \"\\t\", '\"', \"'\", \"=\"]);\n\nexport function findLastDelimiter(text: string): number {\n\tfor (let i = text.length - 1; i >= 0; i -= 1) if (PATH_DELIMITERS.has(text[i] ?? \"\")) return i;\n\treturn -1;\n}\nfunction findUnclosedQuoteStart(text: string): number | null {\n\tlet inQuotes = false;\n\tlet quoteStart = -1;\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) quoteStart = i;\n\t\t}\n\t}\n\treturn inQuotes ? quoteStart : null;\n}\nfunction isTokenStart(text: string, index: number): boolean {\n\treturn index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? \"\");\n}\nexport function extractQuotedPrefix(text: string): string | null {\n\tconst quoteStart = findUnclosedQuoteStart(text);\n\tif (quoteStart === null) return null;\n\tif (quoteStart > 0 && text[quoteStart - 1] === \"@\") {\n\t\tif (!isTokenStart(text, quoteStart - 1)) return null;\n\t\treturn text.slice(quoteStart - 1);\n\t}\n\tif (!isTokenStart(text, quoteStart)) return null;\n\treturn text.slice(quoteStart);\n}\nexport function parsePathPrefix(prefix: string): { rawPrefix: string; isAtPrefix: boolean; isQuotedPrefix: boolean } {\n\tif (prefix.startsWith('@\"')) return { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true };\n\tif (prefix.startsWith('\"')) return { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true };\n\tif (prefix.startsWith(\"@\")) return { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false };\n\treturn { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false };\n}\nexport function 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\treturn needsQuotes ? `${prefix}\"${path}\"` : `${prefix}${path}`;\n}\n"]}
@@ -0,0 +1,50 @@
1
+ export const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
2
+ export function findLastDelimiter(text) {
3
+ for (let i = text.length - 1; i >= 0; i -= 1)
4
+ if (PATH_DELIMITERS.has(text[i] ?? ""))
5
+ return i;
6
+ return -1;
7
+ }
8
+ function findUnclosedQuoteStart(text) {
9
+ let inQuotes = false;
10
+ let quoteStart = -1;
11
+ for (let i = 0; i < text.length; i += 1) {
12
+ if (text[i] === '"') {
13
+ inQuotes = !inQuotes;
14
+ if (inQuotes)
15
+ quoteStart = i;
16
+ }
17
+ }
18
+ return inQuotes ? quoteStart : null;
19
+ }
20
+ function isTokenStart(text, index) {
21
+ return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? "");
22
+ }
23
+ export function extractQuotedPrefix(text) {
24
+ const quoteStart = findUnclosedQuoteStart(text);
25
+ if (quoteStart === null)
26
+ return null;
27
+ if (quoteStart > 0 && text[quoteStart - 1] === "@") {
28
+ if (!isTokenStart(text, quoteStart - 1))
29
+ return null;
30
+ return text.slice(quoteStart - 1);
31
+ }
32
+ if (!isTokenStart(text, quoteStart))
33
+ return null;
34
+ return text.slice(quoteStart);
35
+ }
36
+ export function parsePathPrefix(prefix) {
37
+ if (prefix.startsWith('@"'))
38
+ return { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true };
39
+ if (prefix.startsWith('"'))
40
+ return { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true };
41
+ if (prefix.startsWith("@"))
42
+ return { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false };
43
+ return { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false };
44
+ }
45
+ export function buildCompletionValue(path, options) {
46
+ const needsQuotes = options.isQuotedPrefix || path.includes(" ");
47
+ const prefix = options.isAtPrefix ? "@" : "";
48
+ return needsQuotes ? `${prefix}"${path}"` : `${prefix}${path}`;
49
+ }
50
+ //# sourceMappingURL=autocomplete-path-prefix.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"autocomplete-path-prefix.js","sourceRoot":"","sources":["../src/autocomplete-path-prefix.ts"],"names":[],"mappings":"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","sourcesContent":["export const PATH_DELIMITERS = new Set([\" \", \"\\t\", '\"', \"'\", \"=\"]);\n\nexport function findLastDelimiter(text: string): number {\n\tfor (let i = text.length - 1; i >= 0; i -= 1) if (PATH_DELIMITERS.has(text[i] ?? \"\")) return i;\n\treturn -1;\n}\nfunction findUnclosedQuoteStart(text: string): number | null {\n\tlet inQuotes = false;\n\tlet quoteStart = -1;\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) quoteStart = i;\n\t\t}\n\t}\n\treturn inQuotes ? quoteStart : null;\n}\nfunction isTokenStart(text: string, index: number): boolean {\n\treturn index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? \"\");\n}\nexport function extractQuotedPrefix(text: string): string | null {\n\tconst quoteStart = findUnclosedQuoteStart(text);\n\tif (quoteStart === null) return null;\n\tif (quoteStart > 0 && text[quoteStart - 1] === \"@\") {\n\t\tif (!isTokenStart(text, quoteStart - 1)) return null;\n\t\treturn text.slice(quoteStart - 1);\n\t}\n\tif (!isTokenStart(text, quoteStart)) return null;\n\treturn text.slice(quoteStart);\n}\nexport function parsePathPrefix(prefix: string): { rawPrefix: string; isAtPrefix: boolean; isQuotedPrefix: boolean } {\n\tif (prefix.startsWith('@\"')) return { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true };\n\tif (prefix.startsWith('\"')) return { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true };\n\tif (prefix.startsWith(\"@\")) return { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false };\n\treturn { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false };\n}\nexport function 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\treturn needsQuotes ? `${prefix}\"${path}\"` : `${prefix}${path}`;\n}\n"]}
@@ -49,6 +49,7 @@ export declare class CombinedAutocompleteProvider implements AutocompleteProvide
49
49
  private scopedPathForDisplay;
50
50
  private getFileSuggestions;
51
51
  private scoreEntry;
52
+ private getBaseDirSuggestions;
52
53
  private getFuzzyFileSuggestions;
53
54
  shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean;
54
55
  }
@@ -1 +1 @@
1
- {"version":3,"file":"autocomplete.d.ts","sourceRoot":"","sources":["../src/autocomplete.ts"],"names":[],"mappings":"AA0NA,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,KAAK,SAAS,CAAC,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;AAEnC,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IAGtB,sBAAsB,CAAC,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC,gBAAgB,EAAE,GAAG,IAAI,CAAC,CAAC;CACtF;AAED,MAAM,WAAW,uBAAuB;IACvC,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,oBAAoB;IAGpC,cAAc,CACb,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE;QAAE,MAAM,EAAE,WAAW,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAC/C,OAAO,CAAC,uBAAuB,GAAG,IAAI,CAAC,CAAC;IAI3C,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;IAGF,2BAA2B,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;CAC9F;AAGD,qBAAa,4BAA6B,YAAW,oBAAoB;IACxE,OAAO,CAAC,QAAQ,CAAsC;IACtD,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,MAAM,CAAgB;IAE9B,YAAY,QAAQ,iDAA0C,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,GAAE,MAAM,GAAG,IAAW,EAI7G;IAED,sFAAsF;IACtF,OAAO,CAAC,2BAA2B;IAoC7B,cAAc,CACnB,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE;QAAE,MAAM,EAAE,WAAW,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAC/C,OAAO,CAAC,uBAAuB,GAAG,IAAI,CAAC,CAkLzC;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,CAuG5D;IAGD,OAAO,CAAC,eAAe;IAiBvB,OAAO,CAAC,iBAAiB;IA8BzB,OAAO,CAAC,cAAc;IAWtB,OAAO,CAAC,uBAAuB;IA8B/B,OAAO,CAAC,oBAAoB;IAS5B,OAAO,CAAC,kBAAkB;IAyI1B,OAAO,CAAC,UAAU;YAuBJ,uBAAuB;IAuDrC,2BAA2B,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAa3F;CACD","sourcesContent":["import { spawn } from \"child_process\";\nimport { readdirSync, statSync } from \"fs\";\nimport { homedir } from \"os\";\nimport { basename, dirname, join } from \"path\";\nimport { fuzzyFilter } from \"./fuzzy.ts\";\n\nconst PATH_DELIMITERS = new Set([\" \", \"\\t\", '\"', \"'\", \"=\"]);\n\nfunction toDisplayPath(value: string): string {\n\treturn value.replace(/\\\\/g, \"/\");\n}\n\nfunction escapeRegex(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\nfunction buildFdPathQuery(query: string): string {\n\tconst normalized = toDisplayPath(query);\n\tif (!normalized.includes(\"/\")) {\n\t\treturn normalized;\n\t}\n\n\tconst hasTrailingSeparator = normalized.endsWith(\"/\");\n\tconst trimmed = normalized.replace(/^\\/+|\\/+$/g, \"\");\n\tif (!trimmed) {\n\t\treturn normalized;\n\t}\n\n\tconst separatorPattern = \"[\\\\\\\\/]\";\n\tconst segments = trimmed\n\t\t.split(\"/\")\n\t\t.filter(Boolean)\n\t\t.map((segment) => escapeRegex(segment));\n\tif (segments.length === 0) {\n\t\treturn normalized;\n\t}\n\n\tlet pattern = segments.join(separatorPattern);\n\tif (hasTrailingSeparator) {\n\t\tpattern += separatorPattern;\n\t}\n\treturn pattern;\n}\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)\nasync function walkDirectoryWithFd(\n\tbaseDir: string,\n\tfdPath: string,\n\tquery: string,\n\tmaxResults: number,\n\tsignal: AbortSignal,\n): Promise<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\"--follow\",\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\tif (toDisplayPath(query).includes(\"/\")) {\n\t\targs.push(\"--full-path\");\n\t}\n\n\tif (query) {\n\t\targs.push(buildFdPathQuery(query));\n\t}\n\n\treturn await new Promise((resolve) => {\n\t\tif (signal.aborted) {\n\t\t\tresolve([]);\n\t\t\treturn;\n\t\t}\n\n\t\tconst child = spawn(fdPath, args, {\n\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t});\n\t\tlet stdout = \"\";\n\t\tlet resolved = false;\n\n\t\tconst finish = (results: Array<{ path: string; isDirectory: boolean }>) => {\n\t\t\tif (resolved) return;\n\t\t\tresolved = true;\n\t\t\tsignal.removeEventListener(\"abort\", onAbort);\n\t\t\tresolve(results);\n\t\t};\n\n\t\tconst onAbort = () => {\n\t\t\tif (child.exitCode === null) {\n\t\t\t\tchild.kill(\"SIGKILL\");\n\t\t\t}\n\t\t};\n\n\t\tsignal.addEventListener(\"abort\", onAbort, { once: true });\n\t\tchild.stdout.setEncoding(\"utf-8\");\n\t\tchild.stdout.on(\"data\", (chunk: string) => {\n\t\t\tstdout += chunk;\n\t\t});\n\t\tchild.on(\"error\", () => {\n\t\t\tfinish([]);\n\t\t});\n\t\tchild.on(\"close\", (code) => {\n\t\t\tif (signal.aborted || code !== 0 || !stdout) {\n\t\t\t\tfinish([]);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst lines = stdout.trim().split(\"\\n\").filter(Boolean);\n\t\t\tconst results: Array<{ path: string; isDirectory: boolean }> = [];\n\n\t\t\tfor (const line of lines) {\n\t\t\t\tconst displayLine = toDisplayPath(line);\n\t\t\t\tconst hasTrailingSeparator = displayLine.endsWith(\"/\");\n\t\t\t\tconst normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine;\n\t\t\t\tif (normalizedPath === \".git\" || normalizedPath.startsWith(\".git/\") || normalizedPath.includes(\"/.git/\")) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tresults.push({\n\t\t\t\t\tpath: displayLine,\n\t\t\t\t\tisDirectory: hasTrailingSeparator,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tfinish(results);\n\t\t});\n\t});\n}\n\nexport interface AutocompleteItem {\n\tvalue: string;\n\tlabel: string;\n\tdescription?: string;\n}\n\ntype Awaitable<T> = T | Promise<T>;\n\nexport interface SlashCommand {\n\tname: string;\n\tdescription?: string;\n\targumentHint?: 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): Awaitable<AutocompleteItem[] | null>;\n}\n\nexport interface AutocompleteSuggestions {\n\titems: AutocompleteItem[];\n\tprefix: string; // What we're matching against (e.g., \"/\" or \"src/\")\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\toptions: { signal: AbortSignal; force?: boolean },\n\t): Promise<AutocompleteSuggestions | 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\t// Check if file completion should trigger for explicit Tab completion\n\tshouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;\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(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string, fdPath: string | null = null) {\n\t\tthis.commands = commands;\n\t\tthis.basePath = basePath;\n\t\tthis.fdPath = fdPath;\n\t}\n\n\t/** Skill launcher items shared by `!name` and bare first-token skill autocomplete. */\n\tprivate getSkillLauncherSuggestions(prefix: string): AutocompleteItem[] {\n\t\tconst skillItems = this.commands\n\t\t\t.filter((cmd) => {\n\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\treturn name.startsWith(\"skill:\");\n\t\t\t})\n\t\t\t.map((cmd) => {\n\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\tconst shortName = name.slice(\"skill:\".length);\n\t\t\t\tconst desc = \"description\" in cmd && cmd.description ? cmd.description : \"\";\n\t\t\t\treturn {\n\t\t\t\t\tname: shortName,\n\t\t\t\t\tlabel: shortName,\n\t\t\t\t\tvalue: `!skill:${shortName} `,\n\t\t\t\t\t...(desc && { description: desc }),\n\t\t\t\t};\n\t\t\t});\n\t\tconst omkIndexAvailable = skillItems.some((item) => item.name === \"omk-skills\");\n\t\tconst bangItems = omkIndexAvailable\n\t\t\t? [\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"omk\",\n\t\t\t\t\t\tlabel: \"omk\",\n\t\t\t\t\t\tvalue: \"!omk \",\n\t\t\t\t\t\tdescription: \"Route through OMK role hubs (frontend, backend, loop, plan, security, etc.)\",\n\t\t\t\t\t},\n\t\t\t\t\t...skillItems,\n\t\t\t\t]\n\t\t\t: skillItems;\n\t\treturn fuzzyFilter(bangItems, prefix, (item) => item.name).map((item) => ({\n\t\t\tvalue: item.value,\n\t\t\tlabel: item.label,\n\t\t\t...(item.description && { description: item.description }),\n\t\t}));\n\t}\n\n\tasync getSuggestions(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t\toptions: { signal: AbortSignal; force?: boolean },\n\t): Promise<AutocompleteSuggestions | null> {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\n\t\tconst atPrefix = this.extractAtPrefix(textBeforeCursor);\n\t\tif (atPrefix) {\n\t\t\tconst { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);\n\t\t\tconst suggestions = await this.getFuzzyFileSuggestions(rawPrefix, {\n\t\t\t\tisQuotedPrefix,\n\t\t\t\tsignal: options.signal,\n\t\t\t});\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\tif (textBeforeCursor.startsWith(\"/\")) {\n\t\t\tconst spaceIndex = textBeforeCursor.indexOf(\" \");\n\n\t\t\tif (spaceIndex === -1) {\n\t\t\t\tif (options.force) return null;\n\n\t\t\t\tconst prefix = textBeforeCursor.slice(1);\n\t\t\t\t// Built-in/extension slash commands (exclude skill:* — listed as short names below).\n\t\t\t\tconst commandItems = this.commands\n\t\t\t\t\t.filter((cmd) => {\n\t\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\t\treturn !name.startsWith(\"skill:\");\n\t\t\t\t\t})\n\t\t\t\t\t.map((cmd) => {\n\t\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\t\tconst hint = \"argumentHint\" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;\n\t\t\t\t\t\tconst desc = cmd.description ?? \"\";\n\t\t\t\t\t\tconst fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tname,\n\t\t\t\t\t\t\tlabel: name,\n\t\t\t\t\t\t\tvalue: name,\n\t\t\t\t\t\t\tdescription: fullDesc || undefined,\n\t\t\t\t\t\t};\n\t\t\t\t\t});\n\n\t\t\t\t// Skills as short names so `/frontend` matches without typing `skill:` first.\n\t\t\t\t// value stays `skill:name` so applyCompletion yields `/skill:name `.\n\t\t\t\tconst skillShortItems = this.commands\n\t\t\t\t\t.filter((cmd) => {\n\t\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\t\treturn name.startsWith(\"skill:\");\n\t\t\t\t\t})\n\t\t\t\t\t.map((cmd) => {\n\t\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\t\tconst shortName = name.slice(\"skill:\".length);\n\t\t\t\t\t\tconst desc = \"description\" in cmd && cmd.description ? cmd.description : \"\";\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tname: shortName,\n\t\t\t\t\t\t\tlabel: shortName,\n\t\t\t\t\t\t\tvalue: name,\n\t\t\t\t\t\t\tdescription: desc || undefined,\n\t\t\t\t\t\t};\n\t\t\t\t\t});\n\n\t\t\t\t// Keep full `skill:…` entries when the user types the explicit form.\n\t\t\t\tconst skillFullItems =\n\t\t\t\t\tprefix.startsWith(\"skill\") || prefix.includes(\":\")\n\t\t\t\t\t\t? this.commands\n\t\t\t\t\t\t\t\t.filter((cmd) => {\n\t\t\t\t\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\t\t\t\t\treturn name.startsWith(\"skill:\");\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t.map((cmd) => {\n\t\t\t\t\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\t\t\t\t\tconst desc = \"description\" in cmd && cmd.description ? cmd.description : \"\";\n\t\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\t\tname,\n\t\t\t\t\t\t\t\t\t\tlabel: name,\n\t\t\t\t\t\t\t\t\t\tvalue: name,\n\t\t\t\t\t\t\t\t\t\tdescription: desc || undefined,\n\t\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t: [];\n\n\t\t\t\tconst commandItemsMerged = [...commandItems, ...skillShortItems, ...skillFullItems];\n\t\t\t\tconst filtered = fuzzyFilter(commandItemsMerged, prefix, (item) => item.name).map((item) => ({\n\t\t\t\t\tvalue: item.value,\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}\n\n\t\t\tconst commandName = textBeforeCursor.slice(1, spaceIndex);\n\t\t\tconst argumentText = textBeforeCursor.slice(spaceIndex + 1);\n\n\t\t\tconst command = this.commands.find((cmd) => {\n\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\treturn name === commandName;\n\t\t\t});\n\t\t\tif (command && \"getArgumentCompletions\" in command && command.getArgumentCompletions) {\n\t\t\t\tconst argumentSuggestions = await command.getArgumentCompletions(argumentText);\n\t\t\t\tif (Array.isArray(argumentSuggestions) && argumentSuggestions.length > 0) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\titems: argumentSuggestions,\n\t\t\t\t\t\tprefix: argumentText,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!options.force) return null;\n\t\t}\n\n\t\tconst bangTokenMatch = textBeforeCursor.match(/(?:^|[\\s])!([^\\s]*)$/);\n\t\tif (bangTokenMatch && !textBeforeCursor.endsWith(\"!!\")) {\n\t\t\tconst bangPrefix = bangTokenMatch[1] ?? \"\";\n\t\t\tif (!bangPrefix.startsWith(\"!\")) {\n\t\t\t\tconst filtered = this.getSkillLauncherSuggestions(bangPrefix);\n\t\t\t\tif (filtered.length === 0) return null;\n\t\t\t\treturn {\n\t\t\t\t\titems: filtered,\n\t\t\t\t\tprefix: `!${bangPrefix}`,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Mid-message `/skill` token (word boundary). Absolute paths like `/home/…`\n\t\t// still fall through to path completion because they contain extra `/`.\n\t\t// Selecting a skill rewrites the message to start with `!skill:name` so submit works.\n\t\tconst midSlashSkillMatch = textBeforeCursor.match(/(?:^|[\\s])\\/([a-zA-Z0-9][a-zA-Z0-9:._-]*)$/);\n\t\tif (midSlashSkillMatch && !textBeforeCursor.startsWith(\"/\")) {\n\t\t\tconst skillPrefix = midSlashSkillMatch[1] ?? \"\";\n\t\t\t// Strip optional `skill:` typed by the user when filtering short names.\n\t\t\tconst filterPrefix = skillPrefix.startsWith(\"skill:\") ? skillPrefix.slice(\"skill:\".length) : skillPrefix;\n\t\t\tconst filtered = this.getSkillLauncherSuggestions(filterPrefix);\n\t\t\tif (filtered.length > 0) {\n\t\t\t\treturn {\n\t\t\t\t\titems: filtered,\n\t\t\t\t\tprefix: `/${skillPrefix}`,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Bare skill name at the start of the first line (no leading ! or /).\n\t\t// Selecting a match inserts !skill:name so submit still goes through the skill launcher.\n\t\t// If no skill matches, fall through so path completion can still run (e.g. \"my folder\").\n\t\tif (cursorLine === 0) {\n\t\t\tconst plainSkillMatch = textBeforeCursor.match(/^([a-zA-Z0-9][a-zA-Z0-9.\\-_]*)$/);\n\t\t\tif (plainSkillMatch) {\n\t\t\t\tconst plainPrefix = plainSkillMatch[1] ?? \"\";\n\t\t\t\tconst filtered = this.getSkillLauncherSuggestions(plainPrefix);\n\t\t\t\tif (filtered.length > 0) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\titems: filtered,\n\t\t\t\t\t\tprefix: plainPrefix,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst pathMatch = this.extractPathPrefix(textBeforeCursor, options.force ?? false);\n\t\tif (pathMatch === null) {\n\t\t\treturn null;\n\t\t}\n\n\t\tconst suggestions = this.getFileSuggestions(pathMatch);\n\t\tif (suggestions.length === 0) return null;\n\n\t\treturn {\n\t\t\titems: suggestions,\n\t\t\tprefix: pathMatch,\n\t\t};\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\tif (item.value.startsWith(\"!skill:\") || item.value === \"!omk \") {\n\t\t\t// Mid-message `/prefix` skill pick: move invocation to message start so\n\t\t\t// parseBangInvocation / skill expand can run (they require a leading ! or /skill:).\n\t\t\tif (prefix.startsWith(\"/\") && beforePrefix.trim() !== \"\") {\n\t\t\t\tconst rest = `${beforePrefix}${adjustedAfterCursor}`.replace(/\\s+/g, \" \").trim();\n\t\t\t\tconst newLine = rest ? `${item.value}${rest}` : item.value;\n\t\t\t\tconst newLines = [...lines];\n\t\t\t\tnewLines[cursorLine] = newLine;\n\t\t\t\treturn {\n\t\t\t\t\tlines: newLines,\n\t\t\t\t\tcursorLine,\n\t\t\t\t\tcursorCol: newLine.length,\n\t\t\t\t};\n\t\t\t}\n\t\t\tconst newLine = `${beforePrefix}${item.value}${adjustedAfterCursor}`;\n\t\t\tconst newLines = [...lines];\n\t\t\tnewLines[cursorLine] = newLine;\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,\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 normalizedQuery = toDisplayPath(rawQuery);\n\t\tconst slashIndex = normalizedQuery.lastIndexOf(\"/\");\n\t\tif (slashIndex === -1) {\n\t\t\treturn null;\n\t\t}\n\n\t\tconst displayBase = normalizedQuery.slice(0, slashIndex + 1);\n\t\tconst query = normalizedQuery.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\tconst normalizedRelativePath = toDisplayPath(relativePath);\n\t\tif (displayBase === \"/\") {\n\t\t\treturn `/${normalizedRelativePath}`;\n\t\t}\n\t\treturn `${toDisplayPath(displayBase)}${normalizedRelativePath}`;\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(\"/\") || 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\t// path.join normalizes away ./ prefix, preserve it\n\t\t\t\t\t\tif (displayPrefix.startsWith(\"./\") && !relativePath.startsWith(\"./\")) {\n\t\t\t\t\t\t\trelativePath = `./${relativePath}`;\n\t\t\t\t\t\t}\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\trelativePath = toDisplayPath(relativePath);\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 async getFuzzyFileSuggestions(\n\t\tquery: string,\n\t\toptions: { isQuotedPrefix: boolean; signal: AbortSignal },\n\t): Promise<AutocompleteItem[]> {\n\t\tif (!this.fdPath || options.signal.aborted) {\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 = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);\n\t\t\tif (options.signal.aborted) {\n\t\t\t\treturn [];\n\t\t\t}\n\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\tscoredEntries.sort((a, b) => b.score - a.score);\n\t\t\tconst topEntries = scoredEntries.slice(0, 20);\n\n\t\t\tconst suggestions: AutocompleteItem[] = [];\n\t\t\tfor (const { path: entryPath, isDirectory } of topEntries) {\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// 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 file completion if we're typing a slash command name.\n\t\t// Slash command arguments (for example `/model `) are allowed through so\n\t\t// command-specific argument completions can handle Tab before file fallback.\n\t\tconst leftTrimmed = textBeforeCursor.trimStart();\n\t\tif (leftTrimmed.startsWith(\"/\") && !leftTrimmed.includes(\" \")) {\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n}\n"]}
1
+ {"version":3,"file":"autocomplete.d.ts","sourceRoot":"","sources":["../src/autocomplete.ts"],"names":[],"mappings":"AAsJA,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,KAAK,SAAS,CAAC,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;AAEnC,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IAGtB,sBAAsB,CAAC,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC,gBAAgB,EAAE,GAAG,IAAI,CAAC,CAAC;CACtF;AAED,MAAM,WAAW,uBAAuB;IACvC,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,oBAAoB;IAGpC,cAAc,CACb,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE;QAAE,MAAM,EAAE,WAAW,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAC/C,OAAO,CAAC,uBAAuB,GAAG,IAAI,CAAC,CAAC;IAI3C,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;IAGF,2BAA2B,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;CAC9F;AAGD,qBAAa,4BAA6B,YAAW,oBAAoB;IACxE,OAAO,CAAC,QAAQ,CAAsC;IACtD,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,MAAM,CAAgB;IAE9B,YAAY,QAAQ,iDAA0C,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,GAAE,MAAM,GAAG,IAAW,EAI7G;IAED,sFAAsF;IACtF,OAAO,CAAC,2BAA2B;IAoC7B,cAAc,CACnB,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE;QAAE,MAAM,EAAE,WAAW,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAC/C,OAAO,CAAC,uBAAuB,GAAG,IAAI,CAAC,CAkLzC;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,CAuG5D;IAGD,OAAO,CAAC,eAAe;IAiBvB,OAAO,CAAC,iBAAiB;IA8BzB,OAAO,CAAC,cAAc;IAWtB,OAAO,CAAC,uBAAuB;IA8B/B,OAAO,CAAC,oBAAoB;IAS5B,OAAO,CAAC,kBAAkB;IAyI1B,OAAO,CAAC,UAAU;YAsBJ,qBAAqB;YAarB,uBAAuB;IA8ErC,2BAA2B,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAa3F;CACD","sourcesContent":["import { spawn } from \"child_process\";\nimport { readdirSync, statSync } from \"fs\";\nimport { homedir } from \"os\";\nimport { basename, dirname, join } from \"path\";\nimport {\n\tbuildCompletionValue,\n\textractQuotedPrefix,\n\tfindLastDelimiter,\n\tparsePathPrefix,\n} from \"./autocomplete-path-prefix.ts\";\nimport { fuzzyFilter } from \"./fuzzy.ts\";\n\nfunction toDisplayPath(value: string): string {\n\treturn value.replace(/\\\\/g, \"/\");\n}\n\nfunction escapeRegex(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\nfunction buildFdPathQuery(query: string): string {\n\tconst normalized = toDisplayPath(query);\n\tif (!normalized.includes(\"/\")) {\n\t\treturn normalized;\n\t}\n\n\tconst hasTrailingSeparator = normalized.endsWith(\"/\");\n\tconst trimmed = normalized.replace(/^\\/+|\\/+$/g, \"\");\n\tif (!trimmed) {\n\t\treturn normalized;\n\t}\n\n\tconst separatorPattern = \"[\\\\\\\\/]\";\n\tconst segments = trimmed\n\t\t.split(\"/\")\n\t\t.filter(Boolean)\n\t\t.map((segment) => escapeRegex(segment));\n\tif (segments.length === 0) {\n\t\treturn normalized;\n\t}\n\n\tlet pattern = segments.join(separatorPattern);\n\tif (hasTrailingSeparator) {\n\t\tpattern += separatorPattern;\n\t}\n\treturn pattern;\n}\n\n// Use fd to walk directory tree (fast, respects .gitignore)\nasync function walkDirectoryWithFd(\n\tbaseDir: string,\n\tfdPath: string,\n\tquery: string,\n\tmaxResults: number,\n\tsignal: AbortSignal,\n\tmaxDepth?: number,\n): Promise<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\"--follow\",\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\tif (maxDepth !== undefined) {\n\t\targs.push(\"--max-depth\", String(maxDepth));\n\t}\n\n\tif (toDisplayPath(query).includes(\"/\")) {\n\t\targs.push(\"--full-path\");\n\t}\n\n\tif (query) {\n\t\targs.push(buildFdPathQuery(query));\n\t}\n\n\treturn await new Promise((resolve) => {\n\t\tif (signal.aborted) {\n\t\t\tresolve([]);\n\t\t\treturn;\n\t\t}\n\n\t\tconst child = spawn(fdPath, args, {\n\t\t\tshell: false,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t});\n\t\tlet stdout = \"\";\n\t\tlet resolved = false;\n\n\t\tconst finish = (results: Array<{ path: string; isDirectory: boolean }>) => {\n\t\t\tif (resolved) return;\n\t\t\tresolved = true;\n\t\t\tsignal.removeEventListener(\"abort\", onAbort);\n\t\t\tresolve(results);\n\t\t};\n\n\t\tconst onAbort = () => {\n\t\t\tif (child.exitCode === null) {\n\t\t\t\tchild.kill(\"SIGKILL\");\n\t\t\t}\n\t\t};\n\n\t\tsignal.addEventListener(\"abort\", onAbort, { once: true });\n\t\tchild.stdout.setEncoding(\"utf-8\");\n\t\tchild.stdout.on(\"data\", (chunk: string) => {\n\t\t\tstdout += chunk;\n\t\t});\n\t\tchild.on(\"error\", () => {\n\t\t\tfinish([]);\n\t\t});\n\t\tchild.on(\"close\", (code) => {\n\t\t\tif (signal.aborted || code !== 0 || !stdout) {\n\t\t\t\tfinish([]);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst lines = stdout.trim().split(\"\\n\").filter(Boolean);\n\t\t\tconst results: Array<{ path: string; isDirectory: boolean }> = [];\n\n\t\t\tfor (const line of lines) {\n\t\t\t\tconst displayLine = toDisplayPath(line);\n\t\t\t\tconst hasTrailingSeparator = displayLine.endsWith(\"/\");\n\t\t\t\tconst normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine;\n\t\t\t\tif (normalizedPath === \".git\" || normalizedPath.startsWith(\".git/\") || normalizedPath.includes(\"/.git/\")) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tresults.push({\n\t\t\t\t\tpath: displayLine,\n\t\t\t\t\tisDirectory: hasTrailingSeparator,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tfinish(results);\n\t\t});\n\t});\n}\n\nexport interface AutocompleteItem {\n\tvalue: string;\n\tlabel: string;\n\tdescription?: string;\n}\n\ntype Awaitable<T> = T | Promise<T>;\n\nexport interface SlashCommand {\n\tname: string;\n\tdescription?: string;\n\targumentHint?: 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): Awaitable<AutocompleteItem[] | null>;\n}\n\nexport interface AutocompleteSuggestions {\n\titems: AutocompleteItem[];\n\tprefix: string; // What we're matching against (e.g., \"/\" or \"src/\")\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\toptions: { signal: AbortSignal; force?: boolean },\n\t): Promise<AutocompleteSuggestions | 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\t// Check if file completion should trigger for explicit Tab completion\n\tshouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;\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(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string, fdPath: string | null = null) {\n\t\tthis.commands = commands;\n\t\tthis.basePath = basePath;\n\t\tthis.fdPath = fdPath;\n\t}\n\n\t/** Skill launcher items shared by `!name` and bare first-token skill autocomplete. */\n\tprivate getSkillLauncherSuggestions(prefix: string): AutocompleteItem[] {\n\t\tconst skillItems = this.commands\n\t\t\t.filter((cmd) => {\n\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\treturn name.startsWith(\"skill:\");\n\t\t\t})\n\t\t\t.map((cmd) => {\n\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\tconst shortName = name.slice(\"skill:\".length);\n\t\t\t\tconst desc = \"description\" in cmd && cmd.description ? cmd.description : \"\";\n\t\t\t\treturn {\n\t\t\t\t\tname: shortName,\n\t\t\t\t\tlabel: shortName,\n\t\t\t\t\tvalue: `!skill:${shortName} `,\n\t\t\t\t\t...(desc && { description: desc }),\n\t\t\t\t};\n\t\t\t});\n\t\tconst omkIndexAvailable = skillItems.some((item) => item.name === \"omk-skills\");\n\t\tconst bangItems = omkIndexAvailable\n\t\t\t? [\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"omk\",\n\t\t\t\t\t\tlabel: \"omk\",\n\t\t\t\t\t\tvalue: \"!omk \",\n\t\t\t\t\t\tdescription: \"Route through OMK role hubs (frontend, backend, loop, plan, security, etc.)\",\n\t\t\t\t\t},\n\t\t\t\t\t...skillItems,\n\t\t\t\t]\n\t\t\t: skillItems;\n\t\treturn fuzzyFilter(bangItems, prefix, (item) => item.name).map((item) => ({\n\t\t\tvalue: item.value,\n\t\t\tlabel: item.label,\n\t\t\t...(item.description && { description: item.description }),\n\t\t}));\n\t}\n\n\tasync getSuggestions(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t\toptions: { signal: AbortSignal; force?: boolean },\n\t): Promise<AutocompleteSuggestions | null> {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\n\t\tconst atPrefix = this.extractAtPrefix(textBeforeCursor);\n\t\tif (atPrefix) {\n\t\t\tconst { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);\n\t\t\tconst suggestions = await this.getFuzzyFileSuggestions(rawPrefix, {\n\t\t\t\tisQuotedPrefix,\n\t\t\t\tsignal: options.signal,\n\t\t\t});\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\tif (textBeforeCursor.startsWith(\"/\")) {\n\t\t\tconst spaceIndex = textBeforeCursor.indexOf(\" \");\n\n\t\t\tif (spaceIndex === -1) {\n\t\t\t\tif (options.force) return null;\n\n\t\t\t\tconst prefix = textBeforeCursor.slice(1);\n\t\t\t\t// Built-in/extension slash commands (exclude skill:* — listed as short names below).\n\t\t\t\tconst commandItems = this.commands\n\t\t\t\t\t.filter((cmd) => {\n\t\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\t\treturn !name.startsWith(\"skill:\");\n\t\t\t\t\t})\n\t\t\t\t\t.map((cmd) => {\n\t\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\t\tconst hint = \"argumentHint\" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;\n\t\t\t\t\t\tconst desc = cmd.description ?? \"\";\n\t\t\t\t\t\tconst fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tname,\n\t\t\t\t\t\t\tlabel: name,\n\t\t\t\t\t\t\tvalue: name,\n\t\t\t\t\t\t\tdescription: fullDesc || undefined,\n\t\t\t\t\t\t};\n\t\t\t\t\t});\n\n\t\t\t\t// Skills as short names so `/frontend` matches without typing `skill:` first.\n\t\t\t\t// value stays `skill:name` so applyCompletion yields `/skill:name `.\n\t\t\t\tconst skillShortItems = this.commands\n\t\t\t\t\t.filter((cmd) => {\n\t\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\t\treturn name.startsWith(\"skill:\");\n\t\t\t\t\t})\n\t\t\t\t\t.map((cmd) => {\n\t\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\t\tconst shortName = name.slice(\"skill:\".length);\n\t\t\t\t\t\tconst desc = \"description\" in cmd && cmd.description ? cmd.description : \"\";\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tname: shortName,\n\t\t\t\t\t\t\tlabel: shortName,\n\t\t\t\t\t\t\tvalue: name,\n\t\t\t\t\t\t\tdescription: desc || undefined,\n\t\t\t\t\t\t};\n\t\t\t\t\t});\n\n\t\t\t\t// Keep full `skill:…` entries when the user types the explicit form.\n\t\t\t\tconst skillFullItems =\n\t\t\t\t\tprefix.startsWith(\"skill\") || prefix.includes(\":\")\n\t\t\t\t\t\t? this.commands\n\t\t\t\t\t\t\t\t.filter((cmd) => {\n\t\t\t\t\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\t\t\t\t\treturn name.startsWith(\"skill:\");\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t.map((cmd) => {\n\t\t\t\t\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\t\t\t\t\tconst desc = \"description\" in cmd && cmd.description ? cmd.description : \"\";\n\t\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\t\tname,\n\t\t\t\t\t\t\t\t\t\tlabel: name,\n\t\t\t\t\t\t\t\t\t\tvalue: name,\n\t\t\t\t\t\t\t\t\t\tdescription: desc || undefined,\n\t\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t: [];\n\n\t\t\t\tconst commandItemsMerged = [...commandItems, ...skillShortItems, ...skillFullItems];\n\t\t\t\tconst filtered = fuzzyFilter(commandItemsMerged, prefix, (item) => item.name).map((item) => ({\n\t\t\t\t\tvalue: item.value,\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}\n\n\t\t\tconst commandName = textBeforeCursor.slice(1, spaceIndex);\n\t\t\tconst argumentText = textBeforeCursor.slice(spaceIndex + 1);\n\n\t\t\tconst command = this.commands.find((cmd) => {\n\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\treturn name === commandName;\n\t\t\t});\n\t\t\tif (command && \"getArgumentCompletions\" in command && command.getArgumentCompletions) {\n\t\t\t\tconst argumentSuggestions = await command.getArgumentCompletions(argumentText);\n\t\t\t\tif (Array.isArray(argumentSuggestions) && argumentSuggestions.length > 0) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\titems: argumentSuggestions,\n\t\t\t\t\t\tprefix: argumentText,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!options.force) return null;\n\t\t}\n\n\t\tconst bangTokenMatch = textBeforeCursor.match(/(?:^|[\\s])!([^\\s]*)$/);\n\t\tif (bangTokenMatch && !textBeforeCursor.endsWith(\"!!\")) {\n\t\t\tconst bangPrefix = bangTokenMatch[1] ?? \"\";\n\t\t\tif (!bangPrefix.startsWith(\"!\")) {\n\t\t\t\tconst filtered = this.getSkillLauncherSuggestions(bangPrefix);\n\t\t\t\tif (filtered.length === 0) return null;\n\t\t\t\treturn {\n\t\t\t\t\titems: filtered,\n\t\t\t\t\tprefix: `!${bangPrefix}`,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Mid-message `/skill` token (word boundary). Absolute paths like `/home/…`\n\t\t// still fall through to path completion because they contain extra `/`.\n\t\t// Selecting a skill rewrites the message to start with `!skill:name` so submit works.\n\t\tconst midSlashSkillMatch = textBeforeCursor.match(/(?:^|[\\s])\\/([a-zA-Z0-9][a-zA-Z0-9:._-]*)$/);\n\t\tif (midSlashSkillMatch && !textBeforeCursor.startsWith(\"/\")) {\n\t\t\tconst skillPrefix = midSlashSkillMatch[1] ?? \"\";\n\t\t\t// Strip optional `skill:` typed by the user when filtering short names.\n\t\t\tconst filterPrefix = skillPrefix.startsWith(\"skill:\") ? skillPrefix.slice(\"skill:\".length) : skillPrefix;\n\t\t\tconst filtered = this.getSkillLauncherSuggestions(filterPrefix);\n\t\t\tif (filtered.length > 0) {\n\t\t\t\treturn {\n\t\t\t\t\titems: filtered,\n\t\t\t\t\tprefix: `/${skillPrefix}`,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Bare skill name at the start of the first line (no leading ! or /).\n\t\t// Selecting a match inserts !skill:name so submit still goes through the skill launcher.\n\t\t// If no skill matches, fall through so path completion can still run (e.g. \"my folder\").\n\t\tif (cursorLine === 0) {\n\t\t\tconst plainSkillMatch = textBeforeCursor.match(/^([a-zA-Z0-9][a-zA-Z0-9.\\-_]*)$/);\n\t\t\tif (plainSkillMatch) {\n\t\t\t\tconst plainPrefix = plainSkillMatch[1] ?? \"\";\n\t\t\t\tconst filtered = this.getSkillLauncherSuggestions(plainPrefix);\n\t\t\t\tif (filtered.length > 0) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\titems: filtered,\n\t\t\t\t\t\tprefix: plainPrefix,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst pathMatch = this.extractPathPrefix(textBeforeCursor, options.force ?? false);\n\t\tif (pathMatch === null) {\n\t\t\treturn null;\n\t\t}\n\n\t\tconst suggestions = this.getFileSuggestions(pathMatch);\n\t\tif (suggestions.length === 0) return null;\n\n\t\treturn {\n\t\t\titems: suggestions,\n\t\t\tprefix: pathMatch,\n\t\t};\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\tif (item.value.startsWith(\"!skill:\") || item.value === \"!omk \") {\n\t\t\t// Mid-message `/prefix` skill pick: move invocation to message start so\n\t\t\t// parseBangInvocation / skill expand can run (they require a leading ! or /skill:).\n\t\t\tif (prefix.startsWith(\"/\") && beforePrefix.trim() !== \"\") {\n\t\t\t\tconst rest = `${beforePrefix}${adjustedAfterCursor}`.replace(/\\s+/g, \" \").trim();\n\t\t\t\tconst newLine = rest ? `${item.value}${rest}` : item.value;\n\t\t\t\tconst newLines = [...lines];\n\t\t\t\tnewLines[cursorLine] = newLine;\n\t\t\t\treturn {\n\t\t\t\t\tlines: newLines,\n\t\t\t\t\tcursorLine,\n\t\t\t\t\tcursorCol: newLine.length,\n\t\t\t\t};\n\t\t\t}\n\t\t\tconst newLine = `${beforePrefix}${item.value}${adjustedAfterCursor}`;\n\t\t\tconst newLines = [...lines];\n\t\t\tnewLines[cursorLine] = newLine;\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,\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 normalizedQuery = toDisplayPath(rawQuery);\n\t\tconst slashIndex = normalizedQuery.lastIndexOf(\"/\");\n\t\tif (slashIndex === -1) {\n\t\t\treturn null;\n\t\t}\n\n\t\tconst displayBase = normalizedQuery.slice(0, slashIndex + 1);\n\t\tconst query = normalizedQuery.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\tconst normalizedRelativePath = toDisplayPath(relativePath);\n\t\tif (displayBase === \"/\") {\n\t\t\treturn `/${normalizedRelativePath}`;\n\t\t}\n\t\treturn `${toDisplayPath(displayBase)}${normalizedRelativePath}`;\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(\"/\") || 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\t// path.join normalizes away ./ prefix, preserve it\n\t\t\t\t\t\tif (displayPrefix.startsWith(\"./\") && !relativePath.startsWith(\"./\")) {\n\t\t\t\t\t\t\trelativePath = `./${relativePath}`;\n\t\t\t\t\t\t}\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\trelativePath = toDisplayPath(relativePath);\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\tprivate async getBaseDirSuggestions(\n\t\tbaseDir: string,\n\t\tquery: string,\n\t\tsignal: AbortSignal,\n\t): Promise<Array<{ path: string; isDirectory: boolean }>> {\n\t\tif (!this.fdPath || signal.aborted) {\n\t\t\treturn [];\n\t\t}\n\n\t\treturn await walkDirectoryWithFd(baseDir, this.fdPath, query, 100, signal, 1);\n\t}\n\n\t// Fuzzy file search using fd (fast, respects .gitignore)\n\tprivate async getFuzzyFileSuggestions(\n\t\tquery: string,\n\t\toptions: { isQuotedPrefix: boolean; signal: AbortSignal },\n\t): Promise<AutocompleteItem[]> {\n\t\tif (!this.fdPath || options.signal.aborted) {\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 baseDirEntries = await this.getBaseDirSuggestions(fdBaseDir, fdQuery, options.signal);\n\t\t\tconst recursiveEntries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);\n\t\t\tconst seenPaths = new Set(baseDirEntries.map((entry) => entry.path));\n\t\t\tconst entries = [\n\t\t\t\t...baseDirEntries,\n\t\t\t\t...recursiveEntries.filter((entry) => {\n\t\t\t\t\tif (seenPaths.has(entry.path)) return false;\n\t\t\t\t\tseenPaths.add(entry.path);\n\t\t\t\t\treturn true;\n\t\t\t\t}),\n\t\t\t];\n\t\t\tif (options.signal.aborted) {\n\t\t\t\treturn [];\n\t\t\t}\n\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\tscoredEntries.sort((a, b) => {\n\t\t\t\tconst scoreDiff = b.score - a.score;\n\t\t\t\tif (scoreDiff !== 0) return scoreDiff;\n\n\t\t\t\tconst aDepth = toDisplayPath(a.path).split(\"/\").filter(Boolean).length;\n\t\t\t\tconst bDepth = toDisplayPath(b.path).split(\"/\").filter(Boolean).length;\n\t\t\t\tconst depthDiff = aDepth - bDepth;\n\t\t\t\tif (depthDiff !== 0) return depthDiff;\n\n\t\t\t\tconst lengthDiff = a.path.length - b.path.length;\n\t\t\t\tif (lengthDiff !== 0) return lengthDiff;\n\n\t\t\t\treturn a.path.localeCompare(b.path);\n\t\t\t});\n\t\t\tconst topEntries = scoredEntries.slice(0, 20);\n\n\t\t\tconst suggestions: AutocompleteItem[] = [];\n\t\t\tfor (const { path: entryPath, isDirectory } of topEntries) {\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// 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 file completion if we're typing a slash command name.\n\t\t// Slash command arguments (for example `/model `) are allowed through so\n\t\t// command-specific argument completions can handle Tab before file fallback.\n\t\tconst leftTrimmed = textBeforeCursor.trimStart();\n\t\tif (leftTrimmed.startsWith(\"/\") && !leftTrimmed.includes(\" \")) {\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n}\n"]}
@@ -2,8 +2,8 @@ import { spawn } from "child_process";
2
2
  import { readdirSync, statSync } from "fs";
3
3
  import { homedir } from "os";
4
4
  import { basename, dirname, join } from "path";
5
+ import { buildCompletionValue, extractQuotedPrefix, findLastDelimiter, parsePathPrefix, } from "./autocomplete-path-prefix.js";
5
6
  import { fuzzyFilter } from "./fuzzy.js";
6
- const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
7
7
  function toDisplayPath(value) {
8
8
  return value.replace(/\\/g, "/");
9
9
  }
@@ -34,70 +34,8 @@ function buildFdPathQuery(query) {
34
34
  }
35
35
  return pattern;
36
36
  }
37
- function findLastDelimiter(text) {
38
- for (let i = text.length - 1; i >= 0; i -= 1) {
39
- if (PATH_DELIMITERS.has(text[i] ?? "")) {
40
- return i;
41
- }
42
- }
43
- return -1;
44
- }
45
- function findUnclosedQuoteStart(text) {
46
- let inQuotes = false;
47
- let quoteStart = -1;
48
- for (let i = 0; i < text.length; i += 1) {
49
- if (text[i] === '"') {
50
- inQuotes = !inQuotes;
51
- if (inQuotes) {
52
- quoteStart = i;
53
- }
54
- }
55
- }
56
- return inQuotes ? quoteStart : null;
57
- }
58
- function isTokenStart(text, index) {
59
- return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? "");
60
- }
61
- function extractQuotedPrefix(text) {
62
- const quoteStart = findUnclosedQuoteStart(text);
63
- if (quoteStart === null) {
64
- return null;
65
- }
66
- if (quoteStart > 0 && text[quoteStart - 1] === "@") {
67
- if (!isTokenStart(text, quoteStart - 1)) {
68
- return null;
69
- }
70
- return text.slice(quoteStart - 1);
71
- }
72
- if (!isTokenStart(text, quoteStart)) {
73
- return null;
74
- }
75
- return text.slice(quoteStart);
76
- }
77
- function parsePathPrefix(prefix) {
78
- if (prefix.startsWith('@"')) {
79
- return { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true };
80
- }
81
- if (prefix.startsWith('"')) {
82
- return { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true };
83
- }
84
- if (prefix.startsWith("@")) {
85
- return { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false };
86
- }
87
- return { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false };
88
- }
89
- function buildCompletionValue(path, options) {
90
- const needsQuotes = options.isQuotedPrefix || path.includes(" ");
91
- const prefix = options.isAtPrefix ? "@" : "";
92
- if (!needsQuotes) {
93
- return `${prefix}${path}`;
94
- }
95
- const openQuote = `${prefix}"`;
96
- const closeQuote = '"';
97
- return `${openQuote}${path}${closeQuote}`;
98
- }
99
37
  // Use fd to walk directory tree (fast, respects .gitignore)
100
- async function walkDirectoryWithFd(baseDir, fdPath, query, maxResults, signal) {
38
+ async function walkDirectoryWithFd(baseDir, fdPath, query, maxResults, signal, maxDepth) {
101
39
  const args = [
102
40
  "--base-directory",
103
41
  baseDir,
@@ -116,6 +54,9 @@ async function walkDirectoryWithFd(baseDir, fdPath, query, maxResults, signal) {
116
54
  "--exclude",
117
55
  ".git/**",
118
56
  ];
57
+ if (maxDepth !== undefined) {
58
+ args.push("--max-depth", String(maxDepth));
59
+ }
119
60
  if (toDisplayPath(query).includes("/")) {
120
61
  args.push("--full-path");
121
62
  }
@@ -128,6 +69,7 @@ async function walkDirectoryWithFd(baseDir, fdPath, query, maxResults, signal) {
128
69
  return;
129
70
  }
130
71
  const child = spawn(fdPath, args, {
72
+ shell: false,
131
73
  stdio: ["ignore", "pipe", "pipe"],
132
74
  });
133
75
  let stdout = "";
@@ -722,6 +664,12 @@ export class CombinedAutocompleteProvider {
722
664
  score += 10;
723
665
  return score;
724
666
  }
667
+ async getBaseDirSuggestions(baseDir, query, signal) {
668
+ if (!this.fdPath || signal.aborted) {
669
+ return [];
670
+ }
671
+ return await walkDirectoryWithFd(baseDir, this.fdPath, query, 100, signal, 1);
672
+ }
725
673
  // Fuzzy file search using fd (fast, respects .gitignore)
726
674
  async getFuzzyFileSuggestions(query, options) {
727
675
  if (!this.fdPath || options.signal.aborted) {
@@ -731,7 +679,18 @@ export class CombinedAutocompleteProvider {
731
679
  const scopedQuery = this.resolveScopedFuzzyQuery(query);
732
680
  const fdBaseDir = scopedQuery?.baseDir ?? this.basePath;
733
681
  const fdQuery = scopedQuery?.query ?? query;
734
- const entries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);
682
+ const baseDirEntries = await this.getBaseDirSuggestions(fdBaseDir, fdQuery, options.signal);
683
+ const recursiveEntries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);
684
+ const seenPaths = new Set(baseDirEntries.map((entry) => entry.path));
685
+ const entries = [
686
+ ...baseDirEntries,
687
+ ...recursiveEntries.filter((entry) => {
688
+ if (seenPaths.has(entry.path))
689
+ return false;
690
+ seenPaths.add(entry.path);
691
+ return true;
692
+ }),
693
+ ];
735
694
  if (options.signal.aborted) {
736
695
  return [];
737
696
  }
@@ -741,7 +700,20 @@ export class CombinedAutocompleteProvider {
741
700
  score: fdQuery ? this.scoreEntry(entry.path, fdQuery, entry.isDirectory) : 1,
742
701
  }))
743
702
  .filter((entry) => entry.score > 0);
744
- scoredEntries.sort((a, b) => b.score - a.score);
703
+ scoredEntries.sort((a, b) => {
704
+ const scoreDiff = b.score - a.score;
705
+ if (scoreDiff !== 0)
706
+ return scoreDiff;
707
+ const aDepth = toDisplayPath(a.path).split("/").filter(Boolean).length;
708
+ const bDepth = toDisplayPath(b.path).split("/").filter(Boolean).length;
709
+ const depthDiff = aDepth - bDepth;
710
+ if (depthDiff !== 0)
711
+ return depthDiff;
712
+ const lengthDiff = a.path.length - b.path.length;
713
+ if (lengthDiff !== 0)
714
+ return lengthDiff;
715
+ return a.path.localeCompare(b.path);
716
+ });
745
717
  const topEntries = scoredEntries.slice(0, 20);
746
718
  const suggestions = [];
747
719
  for (const { path: entryPath, isDirectory } of topEntries) {