diff --git a/README.md b/README.md index 41defc0ef..23c83523a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ CodexMonitor is a macOS Tauri app for orchestrating multiple Codex agents across - Git panel with diff stats, file diffs, and commit log; open commits on GitHub when a remote is detected. - Branch list with checkout and create flows. - Model picker, reasoning effort selector, access mode (read-only/current/full-access), and context usage ring. -- Skills menu and composer autocomplete for `$skill` and `@file` tokens. +- Skills menu and composer autocomplete for `$skill`, `/prompts:...`, and `@file` tokens (custom prompts pulled from `~/.codex/prompts`). - Plan panel for per-turn planning updates and turn interruption controls. - Review runs against uncommitted changes, base branch, commits, or custom instructions. - Debug panel for warning/error events and clipboard export. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 71f1cd417..b2770ffea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; mod codex; mod git; +mod prompts; mod settings; mod state; mod storage; @@ -154,7 +155,8 @@ pub fn run() { git::create_git_branch, codex::model_list, codex::account_rate_limits, - codex::skills_list + codex::skills_list, + prompts::prompts_list ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/prompts.rs b/src-tauri/src/prompts.rs new file mode 100644 index 000000000..caa09663c --- /dev/null +++ b/src-tauri/src/prompts.rs @@ -0,0 +1,164 @@ +use serde::Serialize; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use tokio::task; + +#[derive(Serialize, Clone)] +pub(crate) struct CustomPromptEntry { + pub(crate) name: String, + pub(crate) path: String, + pub(crate) description: Option, + #[serde(rename = "argumentHint")] + pub(crate) argument_hint: Option, + pub(crate) content: String, +} + +fn resolve_home_dir() -> Option { + if let Ok(value) = env::var("HOME") { + if !value.trim().is_empty() { + return Some(PathBuf::from(value)); + } + } + if let Ok(value) = env::var("USERPROFILE") { + if !value.trim().is_empty() { + return Some(PathBuf::from(value)); + } + } + None +} + +fn resolve_codex_home() -> Option { + if let Ok(value) = env::var("CODEX_HOME") { + if !value.trim().is_empty() { + let path = PathBuf::from(value.trim()); + if path.exists() { + return path.canonicalize().ok().or(Some(path)); + } + return None; + } + } + resolve_home_dir().map(|home| home.join(".codex")) +} + +fn default_prompts_dir() -> Option { + resolve_codex_home().map(|home| home.join("prompts")) +} + +fn parse_frontmatter(content: &str) -> (Option, Option, String) { + let mut segments = content.split_inclusive('\n'); + let Some(first_segment) = segments.next() else { + return (None, None, String::new()); + }; + let first_line = first_segment.trim_end_matches(['\r', '\n']); + if first_line.trim() != "---" { + return (None, None, content.to_string()); + } + + let mut description: Option = None; + let mut argument_hint: Option = None; + let mut frontmatter_closed = false; + let mut consumed = first_segment.len(); + + for segment in segments { + let line = segment.trim_end_matches(['\r', '\n']); + let trimmed = line.trim(); + + if trimmed == "---" { + frontmatter_closed = true; + consumed += segment.len(); + break; + } + + if trimmed.is_empty() || trimmed.starts_with('#') { + consumed += segment.len(); + continue; + } + + if let Some((key, value)) = trimmed.split_once(':') { + let mut val = value.trim().to_string(); + if val.len() >= 2 { + let bytes = val.as_bytes(); + let first = bytes[0]; + let last = bytes[bytes.len() - 1]; + if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') { + val = val[1..val.len().saturating_sub(1)].to_string(); + } + } + match key.trim().to_ascii_lowercase().as_str() { + "description" => description = Some(val), + "argument-hint" | "argument_hint" => argument_hint = Some(val), + _ => {} + } + } + + consumed += segment.len(); + } + + if !frontmatter_closed { + return (None, None, content.to_string()); + } + + let body = if consumed >= content.len() { + String::new() + } else { + content[consumed..].to_string() + }; + (description, argument_hint, body) +} + +fn discover_prompts_in(dir: &Path) -> Vec { + let mut out: Vec = Vec::new(); + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return out, + }; + + for entry in entries.flatten() { + let path = entry.path(); + let is_file = fs::metadata(&path).map(|m| m.is_file()).unwrap_or(false); + if !is_file { + continue; + } + let is_md = path + .extension() + .and_then(|s| s.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("md")) + .unwrap_or(false); + if !is_md { + continue; + } + let Some(name) = path + .file_stem() + .and_then(|s| s.to_str()) + .map(str::to_string) + else { + continue; + }; + let content = match fs::read_to_string(&path) { + Ok(content) => content, + Err(_) => continue, + }; + let (description, argument_hint, body) = parse_frontmatter(&content); + out.push(CustomPromptEntry { + name, + path: path.to_string_lossy().to_string(), + description, + argument_hint, + content: body, + }); + } + + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +#[tauri::command] +pub(crate) async fn prompts_list(_workspace_id: String) -> Result, String> { + let Some(dir) = default_prompts_dir() else { + return Ok(Vec::new()); + }; + task::spawn_blocking(move || discover_prompts_in(&dir)) + .await + .map_err(|_| "prompt discovery failed".to_string()) +} diff --git a/src/App.tsx b/src/App.tsx index d52fbff15..9e1776a8e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,6 +47,7 @@ import { useGitHubIssues } from "./hooks/useGitHubIssues"; import { useGitRemote } from "./hooks/useGitRemote"; import { useModels } from "./hooks/useModels"; import { useSkills } from "./hooks/useSkills"; +import { useCustomPrompts } from "./hooks/useCustomPrompts"; import { useWorkspaceFiles } from "./hooks/useWorkspaceFiles"; import { useGitBranches } from "./hooks/useGitBranches"; import { useDebugLog } from "./hooks/useDebugLog"; @@ -207,6 +208,7 @@ function MainApp() { setSelectedEffort, } = useModels({ activeWorkspace, onDebug: addDebugEntry }); const { skills } = useSkills({ activeWorkspace, onDebug: addDebugEntry }); + const { prompts } = useCustomPrompts({ activeWorkspace, onDebug: addDebugEntry }); const { files } = useWorkspaceFiles({ activeWorkspace, onDebug: addDebugEntry }); const { branches, @@ -255,6 +257,7 @@ function MainApp() { model: resolvedModel, effort: selectedEffort, accessMode, + customPrompts: prompts, onMessageActivity: refreshGitStatus, }); const { @@ -833,6 +836,7 @@ function MainApp() { accessMode={accessMode} onSelectAccessMode={setAccessMode} skills={skills} + prompts={prompts} files={files} textareaRef={composerInputRef} /> diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index 9a11bad6b..4433770d5 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import type { QueuedMessage, ThreadTokenUsage } from "../types"; +import type { CustomPromptOption, QueuedMessage, ThreadTokenUsage } from "../types"; import { useComposerAutocompleteState } from "../hooks/useComposerAutocompleteState"; import { ComposerInput } from "./ComposerInput"; import { ComposerMetaBar } from "./ComposerMetaBar"; @@ -19,6 +19,7 @@ type ComposerProps = { accessMode: "read-only" | "current" | "full-access"; onSelectAccessMode: (mode: "read-only" | "current" | "full-access") => void; skills: { name: string; description?: string }[]; + prompts: CustomPromptOption[]; files: string[]; contextUsage?: ThreadTokenUsage | null; queuedMessages?: QueuedMessage[]; @@ -52,6 +53,7 @@ export function Composer({ accessMode, onSelectAccessMode, skills, + prompts, files, contextUsage = null, queuedMessages = [], @@ -116,6 +118,7 @@ export function Composer({ selectionStart, disabled, skills, + prompts, files, textareaRef, setText: setComposerText, diff --git a/src/components/ComposerInput.tsx b/src/components/ComposerInput.tsx index d06f499b0..c1992d889 100644 --- a/src/components/ComposerInput.tsx +++ b/src/components/ComposerInput.tsx @@ -189,6 +189,11 @@ export function ComposerInput({ {item.description} )} + {item.hint && ( + + {item.hint} + + )} )} diff --git a/src/hooks/useComposerAutocomplete.ts b/src/hooks/useComposerAutocomplete.ts index 4b32a58ea..d9b5f58e1 100644 --- a/src/hooks/useComposerAutocomplete.ts +++ b/src/hooks/useComposerAutocomplete.ts @@ -5,6 +5,8 @@ export type AutocompleteItem = { label: string; description?: string; insertText?: string; + hint?: string; + cursorOffset?: number; }; export type AutocompleteTrigger = { diff --git a/src/hooks/useComposerAutocompleteState.ts b/src/hooks/useComposerAutocompleteState.ts index a5c29388f..8aa8a3444 100644 --- a/src/hooks/useComposerAutocompleteState.ts +++ b/src/hooks/useComposerAutocompleteState.ts @@ -1,14 +1,21 @@ import { useCallback, useMemo } from "react"; import type { AutocompleteItem } from "./useComposerAutocomplete"; import { useComposerAutocomplete } from "./useComposerAutocomplete"; +import type { CustomPromptOption } from "../types"; +import { + buildPromptInsertText, + findNextPromptArgCursor, + findPromptArgRangeAtCursor, + getPromptArgumentHint, +} from "../utils/customPrompts"; type Skill = { name: string; description?: string }; - type UseComposerAutocompleteStateArgs = { text: string; selectionStart: number | null; disabled: boolean; skills: Skill[]; + prompts: CustomPromptOption[]; files: string[]; textareaRef: React.RefObject; setText: (next: string) => void; @@ -20,6 +27,7 @@ export function useComposerAutocompleteState({ selectionStart, disabled, skills, + prompts, files, textareaRef, setText, @@ -46,7 +54,25 @@ export function useComposerAutocompleteState({ [files], ); - const slashItems = useMemo( + const promptItems = useMemo( + () => + prompts + .filter((prompt) => prompt.name) + .map((prompt) => { + const insert = buildPromptInsertText(prompt); + return { + id: `prompt:${prompt.name}`, + label: `prompts:${prompt.name}`, + description: prompt.description, + hint: getPromptArgumentHint(prompt), + insertText: insert.text, + cursorOffset: insert.cursorOffset, + }; + }), + [prompts], + ); + + const reviewItems = useMemo( () => [ { id: "review", @@ -76,6 +102,11 @@ export function useComposerAutocompleteState({ [], ); + const slashItems = useMemo( + () => [...reviewItems, ...promptItems], + [promptItems, reviewItems], + ); + const triggers = useMemo( () => [ { trigger: "/", items: slashItems }, @@ -106,6 +137,9 @@ export function useComposerAutocompleteState({ } const triggerIndex = Math.max(0, autocompleteRange.start - 1); const triggerChar = text[triggerIndex] ?? ""; + const cursor = selectionStart ?? autocompleteRange.end; + const promptRange = + triggerChar === "@" ? findPromptArgRangeAtCursor(text, cursor) : null; const before = triggerChar === "@" ? text.slice(0, triggerIndex) @@ -115,7 +149,11 @@ export function useComposerAutocompleteState({ const actualInsert = triggerChar === "@" ? insert.replace(/^@+/, "") : insert; - const needsSpace = after.length === 0 ? true : !/^\s/.test(after); + const needsSpace = promptRange + ? false + : after.length === 0 + ? true + : !/^\s/.test(after); const nextText = `${before}${actualInsert}${needsSpace ? " " : ""}${after}`; setText(nextText); closeAutocomplete(); @@ -124,14 +162,28 @@ export function useComposerAutocompleteState({ if (!textarea) { return; } + const insertCursor = Math.min( + actualInsert.length, + Math.max(0, item.cursorOffset ?? actualInsert.length), + ); const cursor = - before.length + actualInsert.length + (needsSpace ? 1 : 0); + before.length + + insertCursor + + (item.cursorOffset === undefined ? (needsSpace ? 1 : 0) : 0); textarea.focus(); textarea.setSelectionRange(cursor, cursor); setSelectionStart(cursor); }); }, - [autocompleteRange, closeAutocomplete, setSelectionStart, setText, text, textareaRef], + [ + autocompleteRange, + closeAutocomplete, + selectionStart, + setSelectionStart, + setText, + text, + textareaRef, + ], ); const handleTextChange = useCallback( @@ -189,6 +241,22 @@ export function useComposerAutocompleteState({ return; } } + if (event.key === "Tab") { + const cursor = selectionStart ?? text.length; + const nextCursor = findNextPromptArgCursor(text, cursor); + if (nextCursor !== null) { + event.preventDefault(); + requestAnimationFrame(() => { + const textarea = textareaRef.current; + if (!textarea) { + return; + } + textarea.focus(); + textarea.setSelectionRange(nextCursor, nextCursor); + setSelectionStart(nextCursor); + }); + } + } }, [ applyAutocomplete, @@ -198,6 +266,10 @@ export function useComposerAutocompleteState({ highlightIndex, isAutocompleteOpen, moveHighlight, + selectionStart, + setSelectionStart, + text, + textareaRef, ], ); diff --git a/src/hooks/useCustomPrompts.ts b/src/hooks/useCustomPrompts.ts new file mode 100644 index 000000000..1d67d55ea --- /dev/null +++ b/src/hooks/useCustomPrompts.ts @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { CustomPromptOption, DebugEntry, WorkspaceInfo } from "../types"; +import { getPromptsList } from "../services/tauri"; + +type UseCustomPromptsOptions = { + activeWorkspace: WorkspaceInfo | null; + onDebug?: (entry: DebugEntry) => void; +}; + +export function useCustomPrompts({ activeWorkspace, onDebug }: UseCustomPromptsOptions) { + const [prompts, setPrompts] = useState([]); + const lastFetchedWorkspaceId = useRef(null); + const inFlight = useRef(false); + + const workspaceId = activeWorkspace?.id ?? null; + const isConnected = Boolean(activeWorkspace?.connected); + + const refreshPrompts = useCallback(async () => { + if (!workspaceId || !isConnected) { + return; + } + if (inFlight.current) { + return; + } + inFlight.current = true; + onDebug?.({ + id: `${Date.now()}-client-prompts-list`, + timestamp: Date.now(), + source: "client", + label: "prompts/list", + payload: { workspaceId }, + }); + try { + const response = await getPromptsList(workspaceId); + onDebug?.({ + id: `${Date.now()}-server-prompts-list`, + timestamp: Date.now(), + source: "server", + label: "prompts/list response", + payload: response, + }); + const rawPrompts = Array.isArray(response) + ? response + : Array.isArray((response as any)?.prompts) + ? (response as any).prompts + : Array.isArray((response as any)?.result?.prompts) + ? (response as any).result.prompts + : Array.isArray((response as any)?.result) + ? (response as any).result + : []; + const data: CustomPromptOption[] = rawPrompts.map((item: any) => ({ + name: String(item.name ?? ""), + path: String(item.path ?? ""), + description: item.description ? String(item.description) : undefined, + argumentHint: item.argumentHint + ? String(item.argumentHint) + : item.argument_hint + ? String(item.argument_hint) + : undefined, + content: String(item.content ?? ""), + })); + setPrompts(data); + lastFetchedWorkspaceId.current = workspaceId; + } catch (error) { + onDebug?.({ + id: `${Date.now()}-client-prompts-list-error`, + timestamp: Date.now(), + source: "error", + label: "prompts/list error", + payload: error instanceof Error ? error.message : String(error), + }); + } finally { + inFlight.current = false; + } + }, [isConnected, onDebug, workspaceId]); + + useEffect(() => { + if (!workspaceId || !isConnected) { + return; + } + if (lastFetchedWorkspaceId.current === workspaceId) { + return; + } + refreshPrompts(); + }, [isConnected, refreshPrompts, workspaceId]); + + const promptOptions = useMemo( + () => prompts.filter((prompt) => prompt.name), + [prompts], + ); + + return { + prompts: promptOptions, + refreshPrompts, + }; +} diff --git a/src/hooks/useThreads.ts b/src/hooks/useThreads.ts index 55576104c..352c72d0e 100644 --- a/src/hooks/useThreads.ts +++ b/src/hooks/useThreads.ts @@ -3,6 +3,7 @@ import type { ApprovalRequest, AppServerEvent, ConversationItem, + CustomPromptOption, DebugEntry, RateLimitSnapshot, ThreadTokenUsage, @@ -31,6 +32,7 @@ import { mergeThreadItems, previewThreadName, } from "../utils/threadItems"; +import { expandCustomPromptText } from "../utils/customPrompts"; import { initialState, threadReducer } from "./useThreadsReducer"; const STORAGE_KEY_THREAD_ACTIVITY = "codexmonitor.threadLastUserActivity"; @@ -77,6 +79,7 @@ type UseThreadsOptions = { model?: string | null; effort?: string | null; accessMode?: "read-only" | "current" | "full-access"; + customPrompts?: CustomPromptOption[]; onMessageActivity?: () => void; }; @@ -328,6 +331,7 @@ export function useThreads({ model, effort, accessMode, + customPrompts = [], onMessageActivity, }: UseThreadsOptions) { const [state, dispatch] = useReducer(threadReducer, initialState); @@ -957,25 +961,41 @@ export function useThreads({ if (!activeWorkspace || (!text.trim() && images.length === 0)) { return; } + const messageText = text.trim(); + const promptExpansion = expandCustomPromptText(messageText, customPrompts); + if (promptExpansion && "error" in promptExpansion) { + if (activeThreadId) { + pushThreadErrorMessage(activeThreadId, promptExpansion.error); + safeMessageActivity(); + } else { + onDebug?.({ + id: `${Date.now()}-client-prompt-expand-error`, + timestamp: Date.now(), + source: "error", + label: "prompt/expand error", + payload: promptExpansion.error, + }); + } + return; + } + const finalText = promptExpansion?.expanded ?? messageText; const threadId = await ensureThreadForActiveWorkspace(); if (!threadId) { return; } - - const messageText = text.trim(); recordThreadActivity(activeWorkspace.id, threadId); dispatch({ type: "addUserMessage", workspaceId: activeWorkspace.id, threadId, - text: messageText, + text: finalText, images, }); dispatch({ type: "setThreadName", workspaceId: activeWorkspace.id, threadId, - name: previewThreadName(messageText, `Agent ${threadId.slice(0, 4)}`), + name: previewThreadName(finalText, `Agent ${threadId.slice(0, 4)}`), }); markProcessing(threadId, true); safeMessageActivity(); @@ -987,7 +1007,7 @@ export function useThreads({ payload: { workspaceId: activeWorkspace.id, threadId, - text: messageText, + text: finalText, images, model, effort, @@ -998,7 +1018,7 @@ export function useThreads({ (await sendUserMessageService( activeWorkspace.id, threadId, - messageText, + finalText, { model, effort, accessMode, images }, )) as Record; onDebug?.({ @@ -1049,8 +1069,10 @@ export function useThreads({ [ activeWorkspace, markProcessing, + activeThreadId, effort, accessMode, + customPrompts, model, onDebug, pushThreadErrorMessage, diff --git a/src/services/tauri.ts b/src/services/tauri.ts index e2ae57c31..89e0ff077 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -185,6 +185,10 @@ export async function getSkillsList(workspaceId: string) { return invoke("skills_list", { workspaceId }); } +export async function getPromptsList(workspaceId: string) { + return invoke("prompts_list", { workspaceId }); +} + export async function getAppSettings(): Promise { return invoke("get_app_settings"); } diff --git a/src/types.ts b/src/types.ts index 3f62a87ac..ada48cfe6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -205,6 +205,14 @@ export type SkillOption = { description?: string; }; +export type CustomPromptOption = { + name: string; + path: string; + description?: string; + argumentHint?: string; + content: string; +}; + export type BranchInfo = { name: string; lastCommit: number; diff --git a/src/utils/customPrompts.ts b/src/utils/customPrompts.ts new file mode 100644 index 000000000..5bcd958a5 --- /dev/null +++ b/src/utils/customPrompts.ts @@ -0,0 +1,359 @@ +import type { CustomPromptOption } from "../types"; + +const PROMPTS_CMD_PREFIX = "prompts"; +const PROMPTS_CMD = `${PROMPTS_CMD_PREFIX}:`; +const PROMPT_ARG_REGEX = /\$[A-Z][A-Z0-9_]*/g; + +export type PromptArgRange = { + start: number; + end: number; +}; + +function normalizeQuotes(input: string) { + return input + .replace(/[\u201C\u201D]/g, "\"") + .replace(/[\u2018\u2019]/g, "'"); +} + +export function promptArgumentNames(content: string) { + const names: string[] = []; + const seen = new Set(); + const matches = content.matchAll(PROMPT_ARG_REGEX); + for (const match of matches) { + const index = match.index ?? 0; + if (index > 0 && content[index - 1] === "$") { + continue; + } + const name = match[0].slice(1); + if (name === "ARGUMENTS") { + continue; + } + if (!seen.has(name)) { + seen.add(name); + names.push(name); + } + } + return names; +} + +export function promptHasNumericPlaceholders(content: string) { + if (content.includes("$ARGUMENTS")) { + return true; + } + for (let i = 0; i + 1 < content.length; i += 1) { + if (content[i] === "$" && /[1-9]/.test(content[i + 1] ?? "")) { + return true; + } + } + return false; +} + +export function getPromptArgumentHint(prompt: CustomPromptOption) { + const hint = prompt.argumentHint?.trim(); + if (hint) { + return hint; + } + const names = promptArgumentNames(prompt.content); + if (names.length > 0) { + return names.map((name) => `${name}=`).join(" "); + } + if (promptHasNumericPlaceholders(prompt.content)) { + return "[args]"; + } + return undefined; +} + +export function buildPromptInsertText(prompt: CustomPromptOption) { + const names = promptArgumentNames(prompt.content); + let text = `${PROMPTS_CMD}${prompt.name}`; + let cursorOffset: number | undefined; + names.forEach((name) => { + if (cursorOffset === undefined) { + cursorOffset = text.length + 1 + name.length + 2; + } + text += ` ${name}=""`; + }); + return { text, cursorOffset }; +} + +export function parseSlashName(line: string) { + if (!line.startsWith("/")) { + return null; + } + const stripped = line.slice(1); + let nameEnd = stripped.length; + for (let index = 0; index < stripped.length; index += 1) { + if (/\s/.test(stripped[index] ?? "")) { + nameEnd = index; + break; + } + } + const name = stripped.slice(0, nameEnd); + if (!name) { + return null; + } + const rest = stripped.slice(nameEnd).trimStart(); + return { name, rest }; +} + +function isPromptCommandLine(line: string) { + return line.startsWith(`/${PROMPTS_CMD}`); +} + +function findPromptArgRangesInLine(line: string): PromptArgRange[] { + if (!isPromptCommandLine(line)) { + return []; + } + const normalized = normalizeQuotes(line); + const ranges: PromptArgRange[] = []; + let index = 0; + while (index < line.length) { + const assignIndex = normalized.indexOf("=\"", index); + if (assignIndex === -1) { + break; + } + const valueStart = assignIndex + 2; + let end = valueStart; + let found = false; + while (end < normalized.length) { + const char = normalized[end]; + if (char === "\"" && line[end - 1] !== "\\") { + found = true; + break; + } + end += 1; + } + if (!found) { + break; + } + ranges.push({ start: valueStart, end }); + index = end + 1; + } + return ranges; +} + +export function findPromptArgRangeAtCursor(text: string, cursor: number) { + const newlineIndex = text.indexOf("\n"); + const lineEnd = newlineIndex === -1 ? text.length : newlineIndex; + if (cursor > lineEnd) { + return null; + } + const line = text.slice(0, lineEnd); + const ranges = findPromptArgRangesInLine(line); + return ranges.find((range) => cursor >= range.start && cursor <= range.end) ?? null; +} + +export function findNextPromptArgCursor(text: string, cursor: number) { + const newlineIndex = text.indexOf("\n"); + const lineEnd = newlineIndex === -1 ? text.length : newlineIndex; + if (cursor > lineEnd) { + return null; + } + const line = text.slice(0, lineEnd); + const ranges = findPromptArgRangesInLine(line); + if (!ranges.length) { + return null; + } + for (let i = 0; i < ranges.length; i += 1) { + const range = ranges[i]; + if (cursor >= range.start && cursor <= range.end) { + return ranges[i + 1]?.start ?? null; + } + if (cursor < range.start) { + return range.start; + } + } + return null; +} + +function splitShlex(input: string) { + const tokens: string[] = []; + let current = ""; + let inSingle = false; + let inDouble = false; + let escaped = false; + + for (const char of input) { + if (escaped) { + current += char; + escaped = false; + continue; + } + + if (!inSingle && char === "\\") { + escaped = true; + continue; + } + + if (!inDouble && char === "'") { + inSingle = !inSingle; + continue; + } + + if (!inSingle && char === '"') { + inDouble = !inDouble; + continue; + } + + if (!inSingle && !inDouble && /\s/.test(char)) { + if (current) { + tokens.push(current); + current = ""; + } + continue; + } + + current += char; + } + + if (escaped) { + current += "\\"; + } + + if (current) { + tokens.push(current); + } + + return tokens; +} + +function parsePositionalArgs(rest: string) { + return splitShlex(normalizeQuotes(rest)); +} + +type PromptArgsError = + | { kind: "MissingAssignment"; token: string } + | { kind: "MissingKey"; token: string }; + +type PromptInputsResult = + | { values: Record } + | { error: PromptArgsError }; + +function formatPromptArgsError(command: string, error: PromptArgsError) { + if (error.kind === "MissingAssignment") { + return `Could not parse ${command}: expected key=value but found '${error.token}'. Wrap values in double quotes if they contain spaces.`; + } + return `Could not parse ${command}: expected a name before '=' in '${error.token}'.`; +} + +function parsePromptInputs(rest: string): PromptInputsResult { + const values: Record = {}; + if (!rest.trim()) { + return { values } as const; + } + const tokens = splitShlex(normalizeQuotes(rest)); + for (const token of tokens) { + const eqIndex = token.indexOf("="); + if (eqIndex <= 0) { + if (eqIndex === 0) { + return { error: { kind: "MissingKey", token } } as const; + } + return { error: { kind: "MissingAssignment", token } } as const; + } + const key = token.slice(0, eqIndex); + const value = token.slice(eqIndex + 1); + values[key] = value; + } + return { values } as const; +} + +function expandNamedPlaceholders(content: string, inputs: Record) { + return content.replace(PROMPT_ARG_REGEX, (match, offset) => { + if (offset > 0 && content[offset - 1] === "$") { + return match; + } + const key = match.slice(1); + return inputs[key] ?? match; + }); +} + +function expandNumericPlaceholders(content: string, args: string[]) { + let output = ""; + let index = 0; + let cachedJoined: string | null = null; + + while (true) { + const next = content.indexOf("$", index); + if (next === -1) { + output += content.slice(index); + break; + } + output += content.slice(index, next); + const rest = content.slice(next); + const nextChar = rest[1]; + + if (nextChar === "$" && rest.length >= 2) { + output += "$$"; + index = next + 2; + continue; + } + + if (nextChar && /[1-9]/.test(nextChar)) { + const argIndex = Number(nextChar) - 1; + if (Number.isFinite(argIndex) && args[argIndex]) { + output += args[argIndex]; + } + index = next + 2; + continue; + } + + if (rest.length > 1 && rest.slice(1).startsWith("ARGUMENTS")) { + if (args.length > 0) { + if (!cachedJoined) { + cachedJoined = args.join(" "); + } + output += cachedJoined; + } + index = next + 1 + "ARGUMENTS".length; + continue; + } + + output += "$"; + index = next + 1; + } + + return output; +} + +export function expandCustomPromptText( + text: string, + prompts: CustomPromptOption[], +): { expanded: string } | { error: string } | null { + const parsed = parseSlashName(text); + if (!parsed) { + return null; + } + if (!parsed.name.startsWith(PROMPTS_CMD)) { + return null; + } + const promptName = parsed.name.slice(PROMPTS_CMD.length); + if (!promptName) { + return null; + } + const prompt = prompts.find((entry) => entry.name === promptName); + if (!prompt) { + return null; + } + + const required = promptArgumentNames(prompt.content); + if (required.length > 0) { + const parsedInputs = parsePromptInputs(parsed.rest); + if ("error" in parsedInputs) { + return { + error: formatPromptArgsError(`/${parsed.name}`, parsedInputs.error), + } as const; + } + const missing = required.filter((name) => !(name in parsedInputs.values)); + if (missing.length > 0) { + return { + error: `Missing required args for /${parsed.name}: ${missing.join(", ")}. Provide as key=value (quote values with spaces).`, + } as const; + } + return { + expanded: expandNamedPlaceholders(prompt.content, parsedInputs.values), + } as const; + } + + const args = parsePositionalArgs(parsed.rest); + return { expanded: expandNumericPlaceholders(prompt.content, args) } as const; +}