Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};

mod codex;
mod git;
mod prompts;
mod settings;
mod state;
mod storage;
Expand Down Expand Up @@ -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");
Expand Down
164 changes: 164 additions & 0 deletions src-tauri/src/prompts.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
#[serde(rename = "argumentHint")]
pub(crate) argument_hint: Option<String>,
pub(crate) content: String,
}

fn resolve_home_dir() -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> {
resolve_codex_home().map(|home| home.join("prompts"))
}

fn parse_frontmatter(content: &str) -> (Option<String>, Option<String>, 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<String> = None;
let mut argument_hint: Option<String> = 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<CustomPromptEntry> {
let mut out: Vec<CustomPromptEntry> = 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<Vec<CustomPromptEntry>, 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())
}
4 changes: 4 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -255,6 +257,7 @@ function MainApp() {
model: resolvedModel,
effort: selectedEffort,
accessMode,
customPrompts: prompts,
onMessageActivity: refreshGitStatus,
});
const {
Expand Down Expand Up @@ -833,6 +836,7 @@ function MainApp() {
accessMode={accessMode}
onSelectAccessMode={setAccessMode}
skills={skills}
prompts={prompts}
files={files}
textareaRef={composerInputRef}
/>
Expand Down
5 changes: 4 additions & 1 deletion src/components/Composer.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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[];
Expand Down Expand Up @@ -52,6 +53,7 @@ export function Composer({
accessMode,
onSelectAccessMode,
skills,
prompts,
files,
contextUsage = null,
queuedMessages = [],
Expand Down Expand Up @@ -116,6 +118,7 @@ export function Composer({
selectionStart,
disabled,
skills,
prompts,
files,
textareaRef,
setText: setComposerText,
Expand Down
5 changes: 5 additions & 0 deletions src/components/ComposerInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ export function ComposerInput({
{item.description}
</span>
)}
{item.hint && (
<span className="composer-suggestion-description">
{item.hint}
</span>
)}
</>
)}
</button>
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/useComposerAutocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export type AutocompleteItem = {
label: string;
description?: string;
insertText?: string;
hint?: string;
cursorOffset?: number;
};

export type AutocompleteTrigger = {
Expand Down
Loading