import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { AutocompleteItem } from "@oh-my-pi/pi-tui"; import { getMCPConfigPath, getProjectDir, logger, sanitizeText } from "@oh-my-pi/pi-utils"; import { formatModelRoleAlias, getKnownRoleIds } from "../config/model-roles"; import { cfgCycleOrder } from "../config/model-settings"; import { readMCPConfigFile } from "../mcp/config-writer"; import { collectMcpServerNames } from "../modes/controllers/mcp-command-controller"; import { InternalUrlRouter } from "../internal-urls"; import { replaceTabs } from "@oh-my-pi/pi-tui/render/render-utils"; import { encodeLocalPath, sessionLocalProtocolOptions } from "../modes/local-file-editor"; import { createModelBrowserSource } from "../modes/model-browser-source"; import { createModelMentionSource, type ModelMentionCandidateSource, } from "@oh-my-pi/pi-tui/prompt/model-mention-autocomplete"; import { getConfiguredThinkingLevelMetadata } from "@oh-my-pi/pi-tui/thinking"; import { expandTilde } from "../tools/path-utils"; import type { SubcommandDef, TuiSlashCommandRuntime } from "./types"; /** Options shared by the declarative subcommand completers. */ interface SubcommandCompletionOptions { /** Mirrors `BuiltinSlashCommand.subcommandOptional`. */ optional?: boolean; } /** Ghost text completing `prefix` to `sub`: its remaining name characters, then its usage. */ function subcommandRemainderHint(sub: SubcommandDef, prefix: string): string | undefined { const hint = sub.name.slice(prefix.length) + (sub.usage ? ` ${sub.usage}` : ""); return hint || undefined; } /** * Build getArgumentCompletions from declarative subcommand definitions. * Returns subcommand names filtered by prefix in the dropdown; each item's * hint is the ghost text the editor shows while that item is selected. * An optional subcommand offers nothing until a prefix is typed, so Enter on * the bare `/name ` submits it instead of accepting the first subcommand. */ export function buildArgumentCompletions( subcommands: SubcommandDef[], options: SubcommandCompletionOptions = {}, ): (prefix: string) => AutocompleteItem[] | null { return (argumentPrefix: string) => { if (argumentPrefix.includes(" ")) return null; // past the subcommand if (options.optional && argumentPrefix.length === 0) return null; const lower = argumentPrefix.toLowerCase(); const matches = subcommands .filter(s => s.name.startsWith(lower)) .map(s => ({ value: `${s.name} `, label: s.name, description: s.description, hint: subcommandRemainderHint(s, lower), })); return matches.length > 0 ? matches : null; }; } /** * Build getArgumentCompletions for `/effort `. The static `subcommands` * list documents the full vocabulary for ACP clients, but the dropdown must * offer only what the active model exposes: suggesting a tier the model lacks * (e.g. `xhigh` on a model capped at `high`) would make the handler answer the * accepted completion with `Unknown thinking level`. Returns null for a model * with no reasoning dial — there is nothing to pick. Reads the viewed session, * which `/effort` targets from a focused subagent. */ export function buildEffortArgumentCompletions( runtime: TuiSlashCommandRuntime, ): (argumentPrefix: string) => AutocompleteItem[] | null { return (argumentPrefix: string) => { if (argumentPrefix.includes(" ")) return null; const lower = argumentPrefix.toLowerCase(); const session = runtime.ctx.viewSession; const current = session.configuredThinkingLevel(); const matches = session .getAvailableEffortSelectors() .filter(level => level.startsWith(lower)) .map(level => { const { description } = getConfiguredThinkingLevelMetadata(level); return { value: `${level} `, label: level, description: level === current ? `${description} (current)` : description, }; }); return matches.length > 0 ? matches : null; }; } /** * Build getInlineHint for `/effort ` from the same live list as the * dropdown, so the ghost text never completes a tier the active model lacks. */ export function buildEffortInlineHint(runtime: TuiSlashCommandRuntime): (argumentText: string) => string | null { return (argumentText: string) => { const prefix = argumentText.trimStart().toLowerCase(); if (prefix.length === 0 || prefix.includes(" ")) return null; const match = runtime.ctx.viewSession.getAvailableEffortSelectors().find(level => level.startsWith(prefix)); return match && match !== prefix ? match.slice(prefix.length) : null; }; } /** /mcp subcommands whose argument is a server name (per their `usage: "..."`). */ const MCP_SERVER_NAME_SUBCOMMANDS: Readonly> = { enable: true, disable: true, test: true, remove: true, reconnect: true, reauth: true, unauth: true, }; /** Subcommands that accept names found only in `userConfig.disabledServers`. */ const MCP_DISABLED_ONLY_ELIGIBLE_SUBCOMMANDS: Readonly> = { enable: true, disable: true, }; /** * Subcommands that accept configured servers whose `enabled` flag is false. * `unauth` can clear persisted credentials without connecting; test, * reconnect, and reauth explicitly require an enabled server. */ const MCP_DISABLED_CONFIG_ELIGIBLE_SUBCOMMANDS: Readonly> = { enable: true, disable: true, unauth: true, }; /** * Build getArgumentCompletions for /mcp. Delegates to the generic * declarative subcommand completer while the subcommand name itself is * still being typed, then switches to MCP server-name completion (sourced * from {@link collectMcpServerNames}) once a recognized server-name * subcommand (enable/disable/test/remove/reconnect/reauth/unauth) is * followed by a space. `remove` gets its own scope-aware completions (see * {@link buildMcpRemoveCompletions}) since — unlike the others — * it only ever succeeds against a config-file entry. Subcommands with a * different argument shape (add, smithery-search, ...) get no argument * completion. */ export function buildMcpArgumentCompletions( subcommands: SubcommandDef[], runtime: TuiSlashCommandRuntime, ): (argumentPrefix: string) => Promise { const genericCompletions = buildArgumentCompletions(subcommands); return async (argumentPrefix: string) => { const spaceIndex = argumentPrefix.indexOf(" "); if (spaceIndex === -1) return genericCompletions(argumentPrefix); const rawSubcommand = argumentPrefix.slice(0, spaceIndex); const lowerSubcommand = rawSubcommand.toLowerCase(); if (MCP_SERVER_NAME_SUBCOMMANDS[lowerSubcommand] !== true) return null; const namePrefix = argumentPrefix.slice(spaceIndex + 1).toLowerCase(); if (lowerSubcommand === "remove") { return await buildMcpRemoveCompletions(rawSubcommand, namePrefix); } let serverNames: string[]; try { serverNames = await collectMcpServerNames( runtime.ctx, undefined, MCP_DISABLED_ONLY_ELIGIBLE_SUBCOMMANDS[lowerSubcommand] === true, MCP_DISABLED_CONFIG_ELIGIBLE_SUBCOMMANDS[lowerSubcommand] === true, ); } catch (error) { logger.warn("MCP server-name autocomplete failed to read config", { error }); return null; } const matches: AutocompleteItem[] = serverNames .filter(name => name.toLowerCase().startsWith(namePrefix)) .map(name => ({ value: `${rawSubcommand} ${name} `, label: name })); return matches.length > 0 ? matches : null; }; } /** * Build `/mcp remove ` completions. Unlike the other server-name * subcommands, `#handleRemove` only ever succeeds against a config-file * `mcpServers` entry in the target scope (project by default, user with an * explicit `--scope user`) — a purely runtime-discovered server has no * config entry to remove and always fails with `Server "" not found * in config.`. Completions are therefore restricted to config-file * names, and a name that exists only in the user config is completed with * `--scope user` appended so the inserted command is directly executable. */ async function buildMcpRemoveCompletions( rawSubcommand: string, namePrefix: string, ): Promise { const cwd = getProjectDir(); let projectNames: string[]; let userNames: string[]; try { const [projectConfig, userConfig] = await Promise.all([ readMCPConfigFile(getMCPConfigPath("project", cwd)), readMCPConfigFile(getMCPConfigPath("user", cwd)), ]); projectNames = Object.keys(projectConfig.mcpServers ?? {}); userNames = Object.keys(userConfig.mcpServers ?? {}); } catch (error) { logger.warn("MCP remove autocomplete failed to read config", { error }); return null; } const projectNameSet = new Set(projectNames); const allNames = new Set([...projectNames, ...userNames]); const matches: AutocompleteItem[] = [...allNames] .filter(name => name.toLowerCase().startsWith(namePrefix)) .map(name => projectNameSet.has(name) ? { value: `${rawSubcommand} ${name} `, label: name } : { value: `${rawSubcommand} ${name} --scope user `, label: `${name} (user)` }, ) .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" })); return matches.length > 0 ? matches : null; } /** * Build getInlineHint from declarative subcommand definitions. * Shows remaining completion + usage as dim ghost text after cursor; an * optional subcommand advertises its choices before anything is typed, since * {@link buildArgumentCompletions} opens no dropdown there. */ export function buildSubcommandInlineHint( subcommands: SubcommandDef[], options: SubcommandCompletionOptions = {}, ): (argumentText: string) => string | null { return (argumentText: string) => { const trimmed = argumentText.trimStart(); const spaceIndex = trimmed.indexOf(" "); if (spaceIndex === -1) { // Still typing subcommand name — show remaining chars + usage const prefix = trimmed.toLowerCase(); if (prefix.length === 0) { return options.optional ? `[${subcommands.map(s => s.name).join("|")}]` : null; } const match = subcommands.find(s => s.name.startsWith(prefix)); return match ? (subcommandRemainderHint(match, prefix) ?? null) : null; } // Subcommand typed — show remaining usage params const subName = trimmed.slice(0, spaceIndex).toLowerCase(); const afterSub = trimmed.slice(spaceIndex + 1); const sub = subcommands.find(s => s.name === subName); if (!sub?.usage) return null; if (afterSub.length > 0) { const usageParts = sub.usage.split(" "); const inputParts = afterSub.trim().split(/\s+/); const remaining = usageParts.slice(inputParts.length); return remaining.length > 0 ? remaining.join(" ") : null; } return sub.usage; }; } /** * Build getInlineHint for commands with a simple static hint string. * Shows the hint only when no arguments have been typed yet. */ export function buildStaticInlineHint(hint: string): (argumentText: string) => string | null { return (argumentText: string) => (argumentText.trim().length === 0 ? hint : null); } /** * Build getArgumentCompletions for `/switch `, ordered like the alt+p * picker for the same query: a leading `@` lists configured role aliases * (ctrl+p cycle roles first, in cycle order); anything else lists the * session's cycle scope (or every authenticated model) as `provider/id`, * fuzzy-ranked with the picker's role/MRU/provider affinity. Any `:level` * suffix already typed is kept out of the match and re-appended. Returning * role matches also keeps `@smol` from falling through to `@`-file mention * completion. */ export function buildModelSelectorCompletions( runtime: TuiSlashCommandRuntime, ): (argumentPrefix: string) => AutocompleteItem[] | null { let rankModels: ModelMentionCandidateSource | undefined; return (argumentPrefix: string) => { if (argumentPrefix.includes(" ")) return null; const suffixIndex = argumentPrefix.indexOf(":"); const suffix = suffixIndex === -1 ? "" : argumentPrefix.slice(suffixIndex); const query = suffixIndex === -1 ? argumentPrefix : argumentPrefix.slice(0, suffixIndex); const matches: AutocompleteItem[] = []; if (query.startsWith("@")) { const { settings } = runtime.ctx; const lower = query.toLowerCase(); for (const role of new Set([...cfgCycleOrder.get(settings), ...getKnownRoleIds(settings)])) { const configured = settings.getModelRole(role); if (!configured) continue; const alias = formatModelRoleAlias(role); if (!alias.toLowerCase().includes(lower)) continue; matches.push({ value: `${alias}${suffix} `, label: alias, description: configured }); } } else { rankModels ??= createModelMentionSource({ source: createModelBrowserSource(runtime.ctx.settings, model => runtime.ctx.session.effectiveServiceTier(model), ), registry: runtime.ctx.session.modelRegistry, scopedModels: () => runtime.ctx.session.scopedModels.map(entry => entry.model), }); for (const { selector, model } of rankModels(query)) { matches.push({ value: `${selector}${suffix} `, label: selector, description: model.name }); } } return matches.length > 0 ? matches : null; }; } /** Trailing path separator: `/` everywhere, `\` too on Windows. */ const TRAILING_SEPARATOR = process.platform === "win32" ? /[\\/]$/ : /\/$/; /** A path whose last segment is `.` or `..` (or a bare `~`) names a directory without listing it. */ const UNTERMINATED_DIRECTORY = process.platform === "win32" ? /(?:^|[\\/])\.{1,2}$|^~$/ : /(?:^|\/)\.{1,2}$|^~$/; /** * Build getArgumentCompletions that suggests directories relative to the * current project directory. Used by /move so users can Tab-complete the * destination directory. * * Tab accepts the highlighted suggestion, so only a prefix that ends in a * separator lists the directory's children. A prefix naming a directory * without one (`..`, `~`, `foo/.`) completes the separator first; otherwise * Tab would jump straight into the first child. */ export function buildDirectoryArgumentCompletions(): (prefix: string) => Promise { return async (argumentPrefix: string) => { const prefix = argumentPrefix.trim(); if (UNTERMINATED_DIRECTORY.test(prefix)) return [{ value: `${prefix}/`, label: `${prefix}/` }]; const cwd = getProjectDir(); const expandedPrefix = expandTilde(prefix); const isAbsolute = path.isAbsolute(expandedPrefix); const resolvedPrefix = isAbsolute ? expandedPrefix : path.join(cwd, expandedPrefix); let searchDir: string; let searchPrefix: string; if (prefix === "" || TRAILING_SEPARATOR.test(prefix)) { searchDir = resolvedPrefix; searchPrefix = ""; } else { searchDir = path.dirname(resolvedPrefix); searchPrefix = path.basename(resolvedPrefix); } try { const entries = await fs.readdir(searchDir, { withFileTypes: true }); const suggestions: AutocompleteItem[] = []; for (const entry of entries) { if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) continue; if (entry.name === ".git") continue; let isDirectory = entry.isDirectory(); if (!isDirectory && entry.isSymbolicLink()) { try { isDirectory = (await fs.stat(path.join(searchDir, entry.name))).isDirectory(); } catch { continue; } } if (!isDirectory) continue; const absoluteValue = path.join(searchDir, entry.name); const displayValue = buildDirectoryCompletionDisplayValue(prefix, isAbsolute, absoluteValue, cwd); suggestions.push({ value: displayValue, label: `${entry.name}/` }); } suggestions.sort((a, b) => a.label.localeCompare(b.label)); return suggestions.length > 0 ? suggestions : null; } catch { return null; } }; } /** `/local` argument: session local:// file names (accepts a `local://` / `local:/` prefix). */ export function buildLocalFileArgumentCompletions( runtime: TuiSlashCommandRuntime, ): (argumentPrefix: string) => Promise { return async (argumentPrefix: string) => { const query = argumentPrefix .trim() .replace(/^local:\/{0,2}/i, "") .toLowerCase(); try { const files = await InternalUrlRouter.instance().complete("local", query, { localProtocolOptions: sessionLocalProtocolOptions(runtime.ctx.sessionManager), }); const matches = (files ?? []) .filter(file => file.value.toLowerCase().startsWith(query)) // `value` is the inserted argument: encode it as a `local://` URL so names // with whitespace or URL metacharacters survive `parseSlashCommand` // trimming unchanged. `label` is display-only; sanitize it so artifact // names with ANSI/C0 bytes cannot escape into the completion popup. .map(file => ({ value: `local://${encodeLocalPath(file.value)}`, label: replaceTabs(sanitizeText(file.value)), })); return matches.length > 0 ? matches : null; } catch { return null; } }; } function buildDirectoryCompletionDisplayValue( prefix: string, isAbsolute: boolean, absoluteValue: string, cwd: string, ): string { // Preserve the user's prefix style where possible, but always return a // value that /move can resolve (absolute or relative) without escaping. const normalized = path.normalize(absoluteValue); if (prefix === "~" || prefix.startsWith("~/")) { const homeRelative = path.relative(os.homedir(), normalized); return `~/${homeRelative.replaceAll("\\", "/")}/`; } // Absolute stays absolute, including Windows drive paths (`C:/…`, `C:\…`). if (isAbsolute) { return `${normalized.replaceAll("\\", "/")}/`; } const relative = path.relative(cwd, normalized).replaceAll("\\", "/"); // The project directory itself: `""` would render as `/`, the filesystem root. if (relative === "") return "./"; return prefix.startsWith("./") ? `./${relative}/` : `${relative}/`; }