// What the agent view's input asks for (PRD §6.3): a task, a shell job, or an // in-view command; plus @ targets (DI-3). import { existsSync, readdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { basename, isAbsolute, join, resolve } from "node:path"; export type Parsed = | { kind: "task"; task: string; cwd?: string } | { kind: "shell"; command: string; cwd?: string } | { kind: "command"; name: string; arg: string }; const isDir = (p: string) => { try { return statSync(p).isDirectory(); } catch { return false; } }; /** An @token's directory: absolute, ~/…, or relative to the view's directory. */ export function resolveAt(token: string, base: string, home = homedir()): string | undefined { const raw = token.replace(/^@/, "").replace(/\/+$/, ""); if (!raw) return undefined; const path = raw === "~" ? home : raw.startsWith("~/") ? join(home, raw.slice(2)) : isAbsolute(raw) ? raw : resolve(base, raw); return isDir(path) ? path : undefined; } /** DI-3: the first @ naming an existing directory targets it and leaves the text. */ function takeAt(text: string, base: string): { text: string; cwd?: string } { const words = text.split(/(\s+)/); for (let i = 0; i < words.length; i++) { const w = words[i]!; if (!w.startsWith("@") || w.length < 2) continue; const dir = resolveAt(w, base); if (!dir) continue; words.splice(i, 1); return { text: words.join("").replace(/\s+/g, " ").trim(), cwd: dir }; } return { text }; } export function parseInput(input: string, base: string): Parsed { const text = input.trim(); if (text.startsWith("!")) { const { text: command, cwd } = takeAt(text.slice(1).trim(), base); return { kind: "shell", command, ...(cwd ? { cwd } : {}) }; } const cmd = /^\/([\w:.-]+)(?:\s+([\s\S]*))?$/.exec(text); if (cmd) return { kind: "command", name: cmd[1]!, arg: (cmd[2] ?? "").trim() }; const { text: task, cwd } = takeAt(text, base); return { kind: "task", task, ...(cwd ? { cwd } : {}) }; } /** The @word being typed at the end of the input, if any. */ export function atPrefix(input: string): string | undefined { const m = /(?:^|\s)@([^\s]*)$/.exec(input); return m ? m[1] : undefined; } /** * DI-3 completion: child git repositories (one level), the repo's worktrees under * .pi/worktrees/, and directories that already have jobs. Shown relative to base. */ export function atCandidates(base: string, jobDirs: string[], prefix = ""): string[] { const out = new Set(); const p = prefix.toLowerCase(); const add = (abs: string) => { const inside = abs.startsWith(`${base}/`); const rel = abs === base ? "." : inside ? abs.slice(base.length + 1) : abs; // what is typed is matched against the shown name (or a directory's last segment), // never against the base path every child shares if (rel.toLowerCase().startsWith(p) || (!inside && basename(abs).toLowerCase().startsWith(p))) out.add(rel); }; const list = (dir: string) => { try { return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".")); } catch { return []; } }; for (const d of list(base)) if (existsSync(join(base, d.name, ".git"))) add(join(base, d.name)); for (const d of list(join(base, ".pi", "worktrees"))) add(join(base, ".pi", "worktrees", d.name)); for (const dir of jobDirs) if (isDir(dir)) add(dir); // then any other child directory, so a plain folder completes too const preferred = out.size; for (const d of list(base)) add(join(base, d.name)); const all = [...out]; const first = all.slice(0, preferred).sort((a, b) => a.length - b.length || a.localeCompare(b)); const rest = all.slice(preferred).sort((a, b) => a.length - b.length || a.localeCompare(b)); return [...first, ...rest].slice(0, 20); } /** pi's thinking levels for the /thinking picker. */ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;