Skip to content

Commit dc30b9e

Browse files
authored
Merge pull request #28 from Afur/feat/custom-prompts-support
feat: add custom prompts support
2 parents d7a2ad2 + e75da22 commit dc30b9e

13 files changed

Lines changed: 755 additions & 14 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ CodexMonitor is a macOS Tauri app for orchestrating multiple Codex agents across
1414
- Git panel with diff stats, file diffs, and commit log; open commits on GitHub when a remote is detected.
1515
- Branch list with checkout and create flows.
1616
- Model picker, reasoning effort selector, access mode (read-only/current/full-access), and context usage ring.
17-
- Skills menu and composer autocomplete for `$skill` and `@file` tokens.
17+
- Skills menu and composer autocomplete for `$skill`, `/prompts:...`, and `@file` tokens (custom prompts pulled from `~/.codex/prompts`).
1818
- Plan panel for per-turn planning updates and turn interruption controls.
1919
- Review runs against uncommitted changes, base branch, commits, or custom instructions.
2020
- Debug panel for warning/error events and clipboard export.

src-tauri/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
33

44
mod codex;
55
mod git;
6+
mod prompts;
67
mod settings;
78
mod state;
89
mod storage;
@@ -154,7 +155,8 @@ pub fn run() {
154155
git::create_git_branch,
155156
codex::model_list,
156157
codex::account_rate_limits,
157-
codex::skills_list
158+
codex::skills_list,
159+
prompts::prompts_list
158160
])
159161
.run(tauri::generate_context!())
160162
.expect("error while running tauri application");

src-tauri/src/prompts.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
use serde::Serialize;
2+
use std::env;
3+
use std::fs;
4+
use std::path::{Path, PathBuf};
5+
use tokio::task;
6+
7+
#[derive(Serialize, Clone)]
8+
pub(crate) struct CustomPromptEntry {
9+
pub(crate) name: String,
10+
pub(crate) path: String,
11+
pub(crate) description: Option<String>,
12+
#[serde(rename = "argumentHint")]
13+
pub(crate) argument_hint: Option<String>,
14+
pub(crate) content: String,
15+
}
16+
17+
fn resolve_home_dir() -> Option<PathBuf> {
18+
if let Ok(value) = env::var("HOME") {
19+
if !value.trim().is_empty() {
20+
return Some(PathBuf::from(value));
21+
}
22+
}
23+
if let Ok(value) = env::var("USERPROFILE") {
24+
if !value.trim().is_empty() {
25+
return Some(PathBuf::from(value));
26+
}
27+
}
28+
None
29+
}
30+
31+
fn resolve_codex_home() -> Option<PathBuf> {
32+
if let Ok(value) = env::var("CODEX_HOME") {
33+
if !value.trim().is_empty() {
34+
let path = PathBuf::from(value.trim());
35+
if path.exists() {
36+
return path.canonicalize().ok().or(Some(path));
37+
}
38+
return None;
39+
}
40+
}
41+
resolve_home_dir().map(|home| home.join(".codex"))
42+
}
43+
44+
fn default_prompts_dir() -> Option<PathBuf> {
45+
resolve_codex_home().map(|home| home.join("prompts"))
46+
}
47+
48+
fn parse_frontmatter(content: &str) -> (Option<String>, Option<String>, String) {
49+
let mut segments = content.split_inclusive('\n');
50+
let Some(first_segment) = segments.next() else {
51+
return (None, None, String::new());
52+
};
53+
let first_line = first_segment.trim_end_matches(['\r', '\n']);
54+
if first_line.trim() != "---" {
55+
return (None, None, content.to_string());
56+
}
57+
58+
let mut description: Option<String> = None;
59+
let mut argument_hint: Option<String> = None;
60+
let mut frontmatter_closed = false;
61+
let mut consumed = first_segment.len();
62+
63+
for segment in segments {
64+
let line = segment.trim_end_matches(['\r', '\n']);
65+
let trimmed = line.trim();
66+
67+
if trimmed == "---" {
68+
frontmatter_closed = true;
69+
consumed += segment.len();
70+
break;
71+
}
72+
73+
if trimmed.is_empty() || trimmed.starts_with('#') {
74+
consumed += segment.len();
75+
continue;
76+
}
77+
78+
if let Some((key, value)) = trimmed.split_once(':') {
79+
let mut val = value.trim().to_string();
80+
if val.len() >= 2 {
81+
let bytes = val.as_bytes();
82+
let first = bytes[0];
83+
let last = bytes[bytes.len() - 1];
84+
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
85+
val = val[1..val.len().saturating_sub(1)].to_string();
86+
}
87+
}
88+
match key.trim().to_ascii_lowercase().as_str() {
89+
"description" => description = Some(val),
90+
"argument-hint" | "argument_hint" => argument_hint = Some(val),
91+
_ => {}
92+
}
93+
}
94+
95+
consumed += segment.len();
96+
}
97+
98+
if !frontmatter_closed {
99+
return (None, None, content.to_string());
100+
}
101+
102+
let body = if consumed >= content.len() {
103+
String::new()
104+
} else {
105+
content[consumed..].to_string()
106+
};
107+
(description, argument_hint, body)
108+
}
109+
110+
fn discover_prompts_in(dir: &Path) -> Vec<CustomPromptEntry> {
111+
let mut out: Vec<CustomPromptEntry> = Vec::new();
112+
let entries = match fs::read_dir(dir) {
113+
Ok(entries) => entries,
114+
Err(_) => return out,
115+
};
116+
117+
for entry in entries.flatten() {
118+
let path = entry.path();
119+
let is_file = fs::metadata(&path).map(|m| m.is_file()).unwrap_or(false);
120+
if !is_file {
121+
continue;
122+
}
123+
let is_md = path
124+
.extension()
125+
.and_then(|s| s.to_str())
126+
.map(|ext| ext.eq_ignore_ascii_case("md"))
127+
.unwrap_or(false);
128+
if !is_md {
129+
continue;
130+
}
131+
let Some(name) = path
132+
.file_stem()
133+
.and_then(|s| s.to_str())
134+
.map(str::to_string)
135+
else {
136+
continue;
137+
};
138+
let content = match fs::read_to_string(&path) {
139+
Ok(content) => content,
140+
Err(_) => continue,
141+
};
142+
let (description, argument_hint, body) = parse_frontmatter(&content);
143+
out.push(CustomPromptEntry {
144+
name,
145+
path: path.to_string_lossy().to_string(),
146+
description,
147+
argument_hint,
148+
content: body,
149+
});
150+
}
151+
152+
out.sort_by(|a, b| a.name.cmp(&b.name));
153+
out
154+
}
155+
156+
#[tauri::command]
157+
pub(crate) async fn prompts_list(_workspace_id: String) -> Result<Vec<CustomPromptEntry>, String> {
158+
let Some(dir) = default_prompts_dir() else {
159+
return Ok(Vec::new());
160+
};
161+
task::spawn_blocking(move || discover_prompts_in(&dir))
162+
.await
163+
.map_err(|_| "prompt discovery failed".to_string())
164+
}

src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { useGitHubIssues } from "./hooks/useGitHubIssues";
4747
import { useGitRemote } from "./hooks/useGitRemote";
4848
import { useModels } from "./hooks/useModels";
4949
import { useSkills } from "./hooks/useSkills";
50+
import { useCustomPrompts } from "./hooks/useCustomPrompts";
5051
import { useWorkspaceFiles } from "./hooks/useWorkspaceFiles";
5152
import { useGitBranches } from "./hooks/useGitBranches";
5253
import { useDebugLog } from "./hooks/useDebugLog";
@@ -207,6 +208,7 @@ function MainApp() {
207208
setSelectedEffort,
208209
} = useModels({ activeWorkspace, onDebug: addDebugEntry });
209210
const { skills } = useSkills({ activeWorkspace, onDebug: addDebugEntry });
211+
const { prompts } = useCustomPrompts({ activeWorkspace, onDebug: addDebugEntry });
210212
const { files } = useWorkspaceFiles({ activeWorkspace, onDebug: addDebugEntry });
211213
const {
212214
branches,
@@ -255,6 +257,7 @@ function MainApp() {
255257
model: resolvedModel,
256258
effort: selectedEffort,
257259
accessMode,
260+
customPrompts: prompts,
258261
onMessageActivity: refreshGitStatus,
259262
});
260263
const {
@@ -833,6 +836,7 @@ function MainApp() {
833836
accessMode={accessMode}
834837
onSelectAccessMode={setAccessMode}
835838
skills={skills}
839+
prompts={prompts}
836840
files={files}
837841
textareaRef={composerInputRef}
838842
/>

src/components/Composer.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCallback, useEffect, useRef, useState } from "react";
2-
import type { QueuedMessage, ThreadTokenUsage } from "../types";
2+
import type { CustomPromptOption, QueuedMessage, ThreadTokenUsage } from "../types";
33
import { useComposerAutocompleteState } from "../hooks/useComposerAutocompleteState";
44
import { ComposerInput } from "./ComposerInput";
55
import { ComposerMetaBar } from "./ComposerMetaBar";
@@ -19,6 +19,7 @@ type ComposerProps = {
1919
accessMode: "read-only" | "current" | "full-access";
2020
onSelectAccessMode: (mode: "read-only" | "current" | "full-access") => void;
2121
skills: { name: string; description?: string }[];
22+
prompts: CustomPromptOption[];
2223
files: string[];
2324
contextUsage?: ThreadTokenUsage | null;
2425
queuedMessages?: QueuedMessage[];
@@ -52,6 +53,7 @@ export function Composer({
5253
accessMode,
5354
onSelectAccessMode,
5455
skills,
56+
prompts,
5557
files,
5658
contextUsage = null,
5759
queuedMessages = [],
@@ -116,6 +118,7 @@ export function Composer({
116118
selectionStart,
117119
disabled,
118120
skills,
121+
prompts,
119122
files,
120123
textareaRef,
121124
setText: setComposerText,

src/components/ComposerInput.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,11 @@ export function ComposerInput({
189189
{item.description}
190190
</span>
191191
)}
192+
{item.hint && (
193+
<span className="composer-suggestion-description">
194+
{item.hint}
195+
</span>
196+
)}
192197
</>
193198
)}
194199
</button>

src/hooks/useComposerAutocomplete.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export type AutocompleteItem = {
55
label: string;
66
description?: string;
77
insertText?: string;
8+
hint?: string;
9+
cursorOffset?: number;
810
};
911

1012
export type AutocompleteTrigger = {

0 commit comments

Comments
 (0)