pyb-ts
Version:
PYB-CLI - Minimal AI Agent with multi-model support and CLI interface
930 lines (929 loc) • 35 kB
JavaScript
import { useState, useCallback, useEffect, useRef } from "react";
import { useInput } from "ink";
import { existsSync, statSync, readdirSync } from "fs";
import { join, dirname, basename, resolve } from "path";
import { getCwd } from "@utils/state";
import { getActiveAgents } from "@utils/agentLoader";
import { getModelManager } from "@utils/model";
import { matchCommands } from "@utils/fuzzyMatcher";
import {
getCommonSystemCommands,
getCommandPriority,
getEssentialCommands,
getMinimalFallbackCommands
} from "@utils/commonUnixCommands";
const INITIAL_STATE = {
suggestions: [],
selectedIndex: 0,
isActive: false,
context: null,
preview: null,
emptyDirMessage: "",
suppressUntil: 0
};
function useUnifiedCompletion({
input,
cursorOffset,
onInputChange,
setCursorOffset,
commands,
onSubmit
}) {
const [state, setState] = useState(INITIAL_STATE);
const updateState = useCallback((updates) => {
setState((prev) => ({ ...prev, ...updates }));
}, []);
const resetCompletion = useCallback(() => {
setState((prev) => ({
...prev,
suggestions: [],
selectedIndex: 0,
isActive: false,
context: null,
preview: null,
emptyDirMessage: ""
}));
}, []);
const activateCompletion = useCallback((suggestions2, context) => {
setState((prev) => ({
...prev,
suggestions: suggestions2,
// Keep the order from generateSuggestions (already sorted with weights)
selectedIndex: 0,
isActive: true,
context,
preview: null
}));
}, []);
const { suggestions, selectedIndex, isActive, emptyDirMessage } = state;
const findCommonPrefix = useCallback((suggestions2) => {
if (suggestions2.length === 0) return "";
if (suggestions2.length === 1) return suggestions2[0].value;
let prefix = suggestions2[0].value;
for (let i = 1; i < suggestions2.length; i++) {
const str = suggestions2[i].value;
let j = 0;
while (j < prefix.length && j < str.length && prefix[j] === str[j]) {
j++;
}
prefix = prefix.slice(0, j);
if (prefix.length === 0) return "";
}
return prefix;
}, []);
const getWordAtCursor = useCallback(() => {
if (!input) return null;
let start = cursorOffset;
while (start > 0) {
const char = input[start - 1];
if (/\s/.test(char)) break;
if (char === "@" && start < cursorOffset) {
start--;
break;
}
if (char === "/") {
const collectedSoFar = input.slice(start, cursorOffset);
if (collectedSoFar.includes("/") || collectedSoFar.includes(".")) {
start--;
continue;
}
if (start > 1) {
const prevChar = input[start - 2];
if (prevChar === "." || prevChar === "~") {
start--;
continue;
}
}
if (start === 1 || start > 1 && /\s/.test(input[start - 2])) {
start--;
break;
}
start--;
continue;
}
if (char === "." && start > 0) {
const nextChar = start < input.length ? input[start] : "";
if (nextChar === "/" || nextChar === ".") {
start--;
continue;
}
}
start--;
}
const word = input.slice(start, cursorOffset);
if (!word) return null;
if (word.startsWith("/")) {
const beforeWord = input.slice(0, start).trim();
const isCommand = beforeWord === "" && !word.includes("/", 1);
return {
type: isCommand ? "command" : "file",
prefix: isCommand ? word.slice(1) : word,
startPos: start,
endPos: cursorOffset
// Use cursor position as end
};
}
if (word.startsWith("@")) {
const content = word.slice(1);
if (word.includes("@", 1)) {
return null;
}
return {
type: "agent",
// This will trigger mixed agent+file completion
prefix: content,
startPos: start,
endPos: cursorOffset
// Use cursor position as end
};
}
return {
type: "file",
prefix: word,
startPos: start,
endPos: cursorOffset
// Use cursor position as end
};
}, [input, cursorOffset]);
const [systemCommands, setSystemCommands] = useState([]);
const [isLoadingCommands, setIsLoadingCommands] = useState(false);
const classifyCommand = useCallback((cmd) => {
const lowerCmd = cmd.toLowerCase();
let score = 0;
if (cmd.length <= 4) score += 40;
else if (cmd.length <= 6) score += 20;
else if (cmd.length <= 8) score += 10;
else if (cmd.length > 15) score -= 30;
if (/^[a-z]+$/.test(lowerCmd)) score += 30;
if (/[A-Z]/.test(cmd)) score -= 15;
if (/\d/.test(cmd)) score -= 20;
if (cmd.includes(".")) score -= 25;
if (cmd.includes("-")) score -= 10;
if (cmd.includes("_")) score -= 15;
const commonWords = ["list", "copy", "move", "find", "print", "show", "edit", "view"];
if (commonWords.some((word) => lowerCmd.includes(word.slice(0, 3)))) score += 25;
const devPrefixes = ["git", "npm", "node", "py", "docker", "kubectl"];
if (devPrefixes.some((prefix) => lowerCmd.startsWith(prefix))) score += 15;
const systemIndicators = ["daemon", "helper", "responder", "service", "d$", "ctl$"];
if (systemIndicators.some(
(indicator) => indicator.endsWith("$") ? lowerCmd.endsWith(indicator.slice(0, -1)) : lowerCmd.includes(indicator)
)) score -= 40;
if (/\.(pl|py|sh|rb|js)$/.test(lowerCmd)) score -= 35;
const buildToolPatterns = ["bindep", "render", "mako", "webpack", "babel", "eslint"];
if (buildToolPatterns.some((pattern) => lowerCmd.includes(pattern))) score -= 25;
const vowelRatio = (lowerCmd.match(/[aeiou]/g) || []).length / lowerCmd.length;
if (vowelRatio < 0.2) score += 15;
if (vowelRatio > 0.5) score -= 10;
if (score >= 50) return "core";
if (score >= 20) return "common";
if (score >= -10) return "dev";
return "system";
}, []);
const loadSystemCommands = useCallback(async () => {
if (systemCommands.length > 0 || isLoadingCommands) return;
setIsLoadingCommands(true);
try {
const { readdirSync: readdirSync2, statSync: statSync2 } = await import("fs");
const pathDirs = (process.env.PATH || "").split(":").filter(Boolean);
const commandSet = /* @__PURE__ */ new Set();
const essentialCommands = getEssentialCommands();
essentialCommands.forEach((cmd) => commandSet.add(cmd));
for (const dir of pathDirs) {
try {
if (readdirSync2 && statSync2) {
const entries = readdirSync2(dir);
for (const entry of entries) {
try {
const fullPath = `${dir}/${entry}`;
const stats = statSync2(fullPath);
if (stats.isFile() && (stats.mode & 73) !== 0) {
commandSet.add(entry);
}
} catch {
}
}
}
} catch {
}
}
const commands2 = Array.from(commandSet).sort();
setSystemCommands(commands2);
} catch (error) {
console.warn("Failed to load system commands, using fallback:", error);
setSystemCommands(getMinimalFallbackCommands());
} finally {
setIsLoadingCommands(false);
}
}, [systemCommands.length, isLoadingCommands]);
useEffect(() => {
loadSystemCommands();
}, [loadSystemCommands]);
const generateCommandSuggestions = useCallback((prefix) => {
const filteredCommands = commands.filter((cmd) => !cmd.isHidden);
if (!prefix) {
return filteredCommands.map((cmd) => ({
value: cmd.userFacingName(),
displayValue: `/${cmd.userFacingName()}`,
type: "command",
score: 100
}));
}
return filteredCommands.filter((cmd) => {
const names = [cmd.userFacingName(), ...cmd.aliases || []];
return names.some((name) => name.toLowerCase().startsWith(prefix.toLowerCase()));
}).map((cmd) => ({
value: cmd.userFacingName(),
displayValue: `/${cmd.userFacingName()}`,
type: "command",
score: 100 - prefix.length + (cmd.userFacingName().startsWith(prefix) ? 10 : 0)
}));
}, [commands]);
const calculateUnixCommandScore = useCallback((cmd, prefix) => {
const result = matchCommands([cmd], prefix);
return result.length > 0 ? result[0].score : 0;
}, []);
const generateUnixCommandSuggestions = useCallback((prefix) => {
if (!prefix) return [];
if (isLoadingCommands) {
return [{
value: "loading...",
displayValue: `\u23F3 Loading system commands...`,
type: "file",
score: 0,
metadata: { isLoading: true }
}];
}
const commonCommands = getCommonSystemCommands(systemCommands);
const uniqueCommands = Array.from(new Set(commonCommands));
const matches = matchCommands(uniqueCommands, prefix);
const boostedMatches = matches.map((match) => {
const priority = getCommandPriority(match.command);
return {
...match,
score: match.score + priority * 0.5
// Add priority boost
};
}).sort((a, b) => b.score - a.score);
let results = boostedMatches.slice(0, 8);
const perfectMatches = boostedMatches.filter((m) => m.score >= 900);
if (perfectMatches.length > 0 && perfectMatches.length <= 3) {
results = perfectMatches;
} else if (boostedMatches.length > 8) {
const goodMatches = boostedMatches.filter((m) => m.score >= 100);
if (goodMatches.length <= 5) {
results = goodMatches;
}
}
return results.map((item) => ({
value: item.command,
displayValue: `$ ${item.command}`,
type: "command",
score: item.score,
metadata: { isUnixCommand: true }
}));
}, [systemCommands, isLoadingCommands]);
const [agentSuggestions, setAgentSuggestions] = useState([]);
const [modelSuggestions, setModelSuggestions] = useState([]);
useEffect(() => {
try {
const modelManager = getModelManager();
const allModels = modelManager.getAllAvailableModelNames();
const suggestions2 = allModels.map((modelId) => {
return {
value: `ask-${modelId}`,
displayValue: `\u{1F99C} ask-${modelId} :: Consult ${modelId} for expert opinion and specialized analysis`,
type: "ask",
score: 90,
// Higher than agents - put ask-models on top
metadata: { modelId }
};
});
setModelSuggestions(suggestions2);
} catch (error) {
console.warn("[useUnifiedCompletion] Failed to load models:", error);
setModelSuggestions([]);
}
}, []);
useEffect(() => {
getActiveAgents().then((agents) => {
const suggestions2 = agents.map((config) => {
let shortDesc = config.whenToUse;
const prefixPatterns = [
/^Use this agent when you need (assistance with: )?/i,
/^Use PROACTIVELY (when|to) /i,
/^Specialized in /i,
/^Implementation specialist for /i,
/^Design validation specialist\.? Use PROACTIVELY to /i,
/^Task validation specialist\.? Use PROACTIVELY to /i,
/^Requirements validation specialist\.? Use PROACTIVELY to /i
];
for (const pattern of prefixPatterns) {
shortDesc = shortDesc.replace(pattern, "");
}
const findSmartBreak = (text, maxLength) => {
if (text.length <= maxLength) return text;
const sentenceEndings = /[.!。!]/;
const firstSentenceMatch = text.search(sentenceEndings);
if (firstSentenceMatch !== -1) {
const firstSentence = text.slice(0, firstSentenceMatch).trim();
if (firstSentence.length >= 5) {
return firstSentence;
}
}
if (text.length > maxLength) {
const commaEndings = /[,,]/;
const commas = [];
let match;
const regex = new RegExp(commaEndings, "g");
while ((match = regex.exec(text)) !== null) {
commas.push(match.index);
}
for (let i = commas.length - 1; i >= 0; i--) {
const commaPos = commas[i];
if (commaPos < maxLength) {
const clause = text.slice(0, commaPos).trim();
if (clause.length >= 5) {
return clause;
}
}
}
}
return text.slice(0, maxLength) + "...";
};
shortDesc = findSmartBreak(shortDesc.trim(), 80);
if (!shortDesc || shortDesc.length < 5) {
shortDesc = findSmartBreak(config.whenToUse, 80);
}
return {
value: `run-agent-${config.agentType}`,
displayValue: `\u{1F464} run-agent-${config.agentType} :: ${shortDesc}`,
// 人类图标 + run-agent前缀 + 简洁描述
type: "agent",
score: 85,
// Lower than ask-models
metadata: config
};
});
setAgentSuggestions(suggestions2);
}).catch((error) => {
console.warn("[useUnifiedCompletion] Failed to load agents:", error);
setAgentSuggestions([]);
});
}, []);
const generateMentionSuggestions = useCallback((prefix) => {
const allSuggestions = [...agentSuggestions, ...modelSuggestions];
if (!prefix) {
return allSuggestions.sort((a, b) => {
if (a.type === "ask" && b.type === "agent") return -1;
if (a.type === "agent" && b.type === "ask") return 1;
return b.score - a.score;
});
}
const candidates = allSuggestions.map((s) => s.value);
const matches = matchCommands(candidates, prefix);
const fuzzyResults = matches.map((match) => {
const suggestion = allSuggestions.find((s) => s.value === match.command);
return {
...suggestion,
score: match.score
// Use fuzzy match score instead of simple scoring
};
}).sort((a, b) => {
if (a.type === "ask" && b.type === "agent") return -1;
if (a.type === "agent" && b.type === "ask") return 1;
return b.score - a.score;
});
return fuzzyResults;
}, [agentSuggestions, modelSuggestions]);
const generateFileSuggestions = useCallback((prefix, isAtReference = false) => {
try {
const cwd = getCwd();
const userPath = prefix || ".";
const isAbsolutePath = userPath.startsWith("/");
const isHomePath = userPath.startsWith("~");
let searchPath;
if (isHomePath) {
searchPath = userPath.replace("~", process.env.HOME || "");
} else if (isAbsolutePath) {
searchPath = userPath;
} else {
searchPath = resolve(cwd, userPath);
}
const endsWithSlash = userPath.endsWith("/");
const searchStat = existsSync(searchPath) ? statSync(searchPath) : null;
let searchDir;
let nameFilter;
if (endsWithSlash || searchStat?.isDirectory()) {
searchDir = searchPath;
nameFilter = "";
} else {
searchDir = dirname(searchPath);
nameFilter = basename(searchPath);
}
if (!existsSync(searchDir)) return [];
const showHidden = nameFilter.startsWith(".") || userPath.includes("/.");
const entries = readdirSync(searchDir).filter((entry) => {
if (!showHidden && entry.startsWith(".")) return false;
if (nameFilter && !entry.toLowerCase().startsWith(nameFilter.toLowerCase())) return false;
return true;
}).sort((a, b) => {
const aPath = join(searchDir, a);
const bPath = join(searchDir, b);
const aIsDir = statSync(aPath).isDirectory();
const bIsDir = statSync(bPath).isDirectory();
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
return a.toLowerCase().localeCompare(b.toLowerCase());
}).slice(0, 25);
return entries.map((entry) => {
const entryPath = join(searchDir, entry);
const isDir = statSync(entryPath).isDirectory();
const icon = isDir ? "\u{1F4C1}" : "\u{1F4C4}";
let value;
if (userPath.includes("/")) {
if (endsWithSlash) {
value = userPath + entry + (isDir ? "/" : "");
} else if (searchStat?.isDirectory()) {
value = userPath + "/" + entry + (isDir ? "/" : "");
} else {
const userDir = userPath.includes("/") ? userPath.substring(0, userPath.lastIndexOf("/")) : "";
value = userDir ? userDir + "/" + entry + (isDir ? "/" : "") : entry + (isDir ? "/" : "");
}
} else {
if (searchStat?.isDirectory()) {
value = userPath + "/" + entry + (isDir ? "/" : "");
} else {
value = entry + (isDir ? "/" : "");
}
}
return {
value,
displayValue: `${icon} ${entry}${isDir ? "/" : ""}`,
type: "file",
score: isDir ? 80 : 70
};
});
} catch {
return [];
}
}, []);
const calculateMatchScore = useCallback((suggestion, prefix) => {
const lowerPrefix = prefix.toLowerCase();
const value = suggestion.value.toLowerCase();
const displayValue = suggestion.displayValue.toLowerCase();
let matchFound = false;
let score = 0;
if (value.startsWith(lowerPrefix)) {
matchFound = true;
score = 100;
} else if (value.includes(lowerPrefix)) {
matchFound = true;
score = 95;
} else if (displayValue.includes(lowerPrefix)) {
matchFound = true;
score = 90;
} else {
const words = value.split(/[-_]/);
if (words.some((word) => word.startsWith(lowerPrefix))) {
matchFound = true;
score = 93;
} else {
const acronym = words.map((word) => word[0]).join("");
if (acronym.startsWith(lowerPrefix)) {
matchFound = true;
score = 88;
}
}
}
if (!matchFound) return 0;
if (suggestion.type === "ask") score += 2;
if (suggestion.type === "agent") score += 1;
return score;
}, []);
const generateSmartMentionSuggestions = useCallback((prefix, sourceContext = "file") => {
if (!prefix || prefix.length < 2) return [];
const allSuggestions = [...agentSuggestions, ...modelSuggestions];
return allSuggestions.map((suggestion) => {
const matchScore = calculateMatchScore(suggestion, prefix);
if (matchScore === 0) return null;
return {
...suggestion,
score: matchScore,
isSmartMatch: true,
originalContext: sourceContext,
// Only modify display for clarity, keep value clean
displayValue: `\u{1F3AF} ${suggestion.displayValue}`
};
}).filter(Boolean).sort((a, b) => b.score - a.score).slice(0, 5);
}, [agentSuggestions, modelSuggestions, calculateMatchScore]);
const generateSuggestions = useCallback((context) => {
switch (context.type) {
case "command":
return generateCommandSuggestions(context.prefix);
case "agent": {
const mentionSuggestions = generateMentionSuggestions(context.prefix);
const fileSuggestions = generateFileSuggestions(context.prefix, true);
const weightedSuggestions = [
...mentionSuggestions.map((s) => ({
...s,
// In @ context, agents/models get high priority
weightedScore: s.score + 150
})),
...fileSuggestions.map((s) => ({
...s,
// Files get lower priority but still visible
weightedScore: s.score + 10
// Small boost to ensure visibility
}))
];
return weightedSuggestions.sort((a, b) => b.weightedScore - a.weightedScore).map(({ weightedScore, ...suggestion }) => suggestion);
}
case "file": {
const fileSuggestions = generateFileSuggestions(context.prefix, false);
const unixSuggestions = generateUnixCommandSuggestions(context.prefix);
const mentionMatches = generateMentionSuggestions(context.prefix).map((s) => ({
...s,
isSmartMatch: true,
// Show that @ will be added when selected
displayValue: `\u2192 ${s.displayValue}`
// Arrow to indicate it will transform
}));
const weightedSuggestions = [
...unixSuggestions.map((s) => ({
...s,
// Unix commands get boost, but exact matches get huge boost
sourceWeight: s.score >= 1e4 ? 5e3 : 200,
// Exact match gets massive boost
weightedScore: s.score >= 1e4 ? s.score + 5e3 : s.score + 200
})),
...mentionMatches.map((s) => ({
...s,
// Agents/models get medium priority boost (but less to avoid overriding exact Unix)
sourceWeight: 50,
weightedScore: s.score + 50
})),
...fileSuggestions.map((s) => ({
...s,
// Files get no boost (baseline)
sourceWeight: 0,
weightedScore: s.score
}))
];
const seen = /* @__PURE__ */ new Set();
const deduplicatedResults = weightedSuggestions.sort((a, b) => b.weightedScore - a.weightedScore).filter((item) => {
if (seen.has(item.value)) return false;
seen.add(item.value);
return true;
}).map(({ weightedScore, sourceWeight, ...suggestion }) => suggestion);
return deduplicatedResults;
}
default:
return [];
}
}, [generateCommandSuggestions, generateMentionSuggestions, generateFileSuggestions, generateUnixCommandSuggestions, generateSmartMentionSuggestions]);
const completeWith = useCallback((suggestion, context) => {
let completion;
if (context.type === "command") {
completion = `/${suggestion.value} `;
} else if (context.type === "agent") {
if (suggestion.type === "agent") {
completion = `@${suggestion.value} `;
} else if (suggestion.type === "ask") {
completion = `@${suggestion.value} `;
} else {
const isDirectory = suggestion.value.endsWith("/");
completion = `@${suggestion.value}${isDirectory ? "" : " "}`;
}
} else {
if (suggestion.isSmartMatch) {
completion = `@${suggestion.value} `;
} else {
const isDirectory = suggestion.value.endsWith("/");
completion = suggestion.value + (isDirectory ? "" : " ");
}
}
let actualEndPos;
if (context.type === "file" && suggestion.value.startsWith("/") && !suggestion.isSmartMatch) {
let end = context.startPos;
while (end < input.length && input[end] !== " " && input[end] !== "\n") {
end++;
}
actualEndPos = end;
} else {
const currentWord = input.slice(context.startPos);
const nextSpaceIndex = currentWord.indexOf(" ");
actualEndPos = nextSpaceIndex === -1 ? input.length : context.startPos + nextSpaceIndex;
}
const newInput = input.slice(0, context.startPos) + completion + input.slice(actualEndPos);
onInputChange(newInput);
setCursorOffset(context.startPos + completion.length);
}, [input, onInputChange, setCursorOffset, onSubmit, commands]);
const partialComplete = useCallback((prefix, context) => {
const completion = context.type === "command" ? `/${prefix}` : context.type === "agent" ? `@${prefix}` : prefix;
const newInput = input.slice(0, context.startPos) + completion + input.slice(context.endPos);
onInputChange(newInput);
setCursorOffset(context.startPos + completion.length);
}, [input, onInputChange, setCursorOffset]);
useInput((input_str, key) => {
if (!key.tab) return false;
if (key.shift) return false;
const context = getWordAtCursor();
if (!context) return false;
if (state.isActive && state.suggestions.length > 0) {
const nextIndex = (state.selectedIndex + 1) % state.suggestions.length;
const nextSuggestion = state.suggestions[nextIndex];
if (state.context) {
const currentWord = input.slice(state.context.startPos);
const wordEnd = currentWord.search(/\s/);
const actualEndPos = wordEnd === -1 ? input.length : state.context.startPos + wordEnd;
let preview;
if (state.context.type === "command") {
preview = `/${nextSuggestion.value}`;
} else if (state.context.type === "agent") {
preview = `@${nextSuggestion.value}`;
} else if (nextSuggestion.isSmartMatch) {
preview = `@${nextSuggestion.value}`;
} else {
preview = nextSuggestion.value;
}
const newInput = input.slice(0, state.context.startPos) + preview + input.slice(actualEndPos);
onInputChange(newInput);
setCursorOffset(state.context.startPos + preview.length);
updateState({
selectedIndex: nextIndex,
preview: {
isActive: true,
originalInput: input,
wordRange: [state.context.startPos, state.context.startPos + preview.length]
}
});
}
return true;
}
const currentSuggestions = generateSuggestions(context);
if (currentSuggestions.length === 0) {
return false;
} else if (currentSuggestions.length === 1) {
completeWith(currentSuggestions[0], context);
return true;
} else {
activateCompletion(currentSuggestions, context);
const firstSuggestion = currentSuggestions[0];
const currentWord = input.slice(context.startPos);
const wordEnd = currentWord.search(/\s/);
const actualEndPos = wordEnd === -1 ? input.length : context.startPos + wordEnd;
let preview;
if (context.type === "command") {
preview = `/${firstSuggestion.value}`;
} else if (context.type === "agent") {
preview = `@${firstSuggestion.value}`;
} else if (firstSuggestion.isSmartMatch) {
preview = `@${firstSuggestion.value}`;
} else {
preview = firstSuggestion.value;
}
const newInput = input.slice(0, context.startPos) + preview + input.slice(actualEndPos);
onInputChange(newInput);
setCursorOffset(context.startPos + preview.length);
updateState({
preview: {
isActive: true,
originalInput: input,
wordRange: [context.startPos, context.startPos + preview.length]
}
});
return true;
}
});
useInput((inputChar, key) => {
if (key.return && state.isActive && state.suggestions.length > 0) {
const selectedSuggestion = state.suggestions[state.selectedIndex];
if (selectedSuggestion && state.context) {
let completion;
if (state.context.type === "command") {
completion = `/${selectedSuggestion.value} `;
} else if (state.context.type === "agent") {
if (selectedSuggestion.type === "agent") {
completion = `@${selectedSuggestion.value} `;
} else if (selectedSuggestion.type === "ask") {
completion = `@${selectedSuggestion.value} `;
} else {
completion = `@${selectedSuggestion.value} `;
}
} else if (selectedSuggestion.isSmartMatch) {
completion = `@${selectedSuggestion.value} `;
} else {
completion = selectedSuggestion.value + " ";
}
const currentWord = input.slice(state.context.startPos);
const nextSpaceIndex = currentWord.indexOf(" ");
const actualEndPos = nextSpaceIndex === -1 ? input.length : state.context.startPos + nextSpaceIndex;
const newInput = input.slice(0, state.context.startPos) + completion + input.slice(actualEndPos);
onInputChange(newInput);
setCursorOffset(state.context.startPos + completion.length);
}
resetCompletion();
return true;
}
if (!state.isActive || state.suggestions.length === 0) return false;
const handleNavigation = (newIndex) => {
const preview = state.suggestions[newIndex].value;
if (state.preview?.isActive && state.context) {
const newInput = input.slice(0, state.context.startPos) + preview + input.slice(state.preview.wordRange[1]);
onInputChange(newInput);
setCursorOffset(state.context.startPos + preview.length);
updateState({
selectedIndex: newIndex,
preview: {
...state.preview,
wordRange: [state.context.startPos, state.context.startPos + preview.length]
}
});
} else {
updateState({ selectedIndex: newIndex });
}
};
if (key.downArrow) {
const nextIndex = (state.selectedIndex + 1) % state.suggestions.length;
handleNavigation(nextIndex);
return true;
}
if (key.upArrow) {
const nextIndex = state.selectedIndex === 0 ? state.suggestions.length - 1 : state.selectedIndex - 1;
handleNavigation(nextIndex);
return true;
}
if (inputChar === " " && state.isActive && state.suggestions.length > 0) {
const selectedSuggestion = state.suggestions[state.selectedIndex];
const isDirectory = selectedSuggestion.value.endsWith("/");
if (!state.context) return false;
const currentWordAtContext = input.slice(
state.context.startPos,
state.context.startPos + selectedSuggestion.value.length
);
if (currentWordAtContext !== selectedSuggestion.value) {
completeWith(selectedSuggestion, state.context);
}
resetCompletion();
if (isDirectory) {
setTimeout(() => {
const newContext = {
...state.context,
prefix: selectedSuggestion.value,
endPos: state.context.startPos + selectedSuggestion.value.length
};
const newSuggestions = generateSuggestions(newContext);
if (newSuggestions.length > 0) {
activateCompletion(newSuggestions, newContext);
} else {
updateState({
emptyDirMessage: `Directory is empty: ${selectedSuggestion.value}`
});
setTimeout(() => updateState({ emptyDirMessage: "" }), 3e3);
}
}, 50);
}
return true;
}
if (key.rightArrow) {
const selectedSuggestion = state.suggestions[state.selectedIndex];
const isDirectory = selectedSuggestion.value.endsWith("/");
if (!state.context) return false;
const currentWordAtContext = input.slice(
state.context.startPos,
state.context.startPos + selectedSuggestion.value.length
);
if (currentWordAtContext !== selectedSuggestion.value) {
completeWith(selectedSuggestion, state.context);
}
resetCompletion();
if (isDirectory) {
setTimeout(() => {
const newContext = {
...state.context,
prefix: selectedSuggestion.value,
endPos: state.context.startPos + selectedSuggestion.value.length
};
const newSuggestions = generateSuggestions(newContext);
if (newSuggestions.length > 0) {
activateCompletion(newSuggestions, newContext);
} else {
updateState({
emptyDirMessage: `Directory is empty: ${selectedSuggestion.value}`
});
setTimeout(() => updateState({ emptyDirMessage: "" }), 3e3);
}
}, 50);
}
return true;
}
if (key.escape) {
if (state.preview?.isActive && state.context) {
onInputChange(state.preview.originalInput);
setCursorOffset(state.context.startPos + state.context.prefix.length);
}
resetCompletion();
return true;
}
return false;
});
useInput((input_str, key) => {
if (key.backspace || key.delete) {
if (state.isActive) {
resetCompletion();
const suppressionTime = input.length > 10 ? 200 : 100;
updateState({
suppressUntil: Date.now() + suppressionTime
});
return true;
}
}
return false;
});
const lastInputRef = useRef("");
useEffect(() => {
if (lastInputRef.current === input) return;
const inputLengthChange = Math.abs(input.length - lastInputRef.current.length);
const isHistoryNavigation = (inputLengthChange > 10 || // Large content change
inputLengthChange > 5 && !input.includes(lastInputRef.current.slice(-5))) && input !== lastInputRef.current;
lastInputRef.current = input;
if (state.preview?.isActive || Date.now() < state.suppressUntil) {
return;
}
if (isHistoryNavigation && state.isActive) {
resetCompletion();
return;
}
const context = getWordAtCursor();
if (context && shouldAutoTrigger(context)) {
const newSuggestions = generateSuggestions(context);
if (newSuggestions.length === 0) {
resetCompletion();
} else if (newSuggestions.length === 1 && shouldAutoHideSingleMatch(newSuggestions[0], context)) {
resetCompletion();
} else {
activateCompletion(newSuggestions, context);
}
} else if (state.context) {
const contextChanged = !context || state.context.type !== context.type || state.context.startPos !== context.startPos || !context.prefix.startsWith(state.context.prefix);
if (contextChanged) {
resetCompletion();
}
}
}, [input, cursorOffset]);
const shouldAutoTrigger = useCallback((context) => {
switch (context.type) {
case "command":
return true;
case "agent":
return true;
case "file":
const prefix = context.prefix;
if (prefix.startsWith("./") || prefix.startsWith("../") || prefix.startsWith("/") || prefix.startsWith("~") || prefix.includes("/")) {
return true;
}
if (prefix.startsWith(".") && prefix.length >= 2) {
return true;
}
return false;
default:
return false;
}
}, []);
const shouldAutoHideSingleMatch = useCallback((suggestion, context) => {
const currentInput = input.slice(context.startPos, context.endPos);
if (context.type === "file") {
if (suggestion.value.endsWith("/")) {
return false;
}
if (currentInput === suggestion.value) {
return true;
}
if (currentInput.endsWith("/" + suggestion.value) || currentInput.endsWith(suggestion.value)) {
return true;
}
return false;
}
if (context.type === "command") {
const fullCommand = `/${suggestion.value}`;
const matches = currentInput === fullCommand;
return matches;
}
if (context.type === "agent") {
const fullAgent = `@${suggestion.value}`;
const matches = currentInput === fullAgent;
return matches;
}
return false;
}, [input]);
return {
suggestions,
selectedIndex,
isActive,
emptyDirMessage
};
}
export {
useUnifiedCompletion
};
//# sourceMappingURL=useUnifiedCompletion.js.map