Skip to content
Open
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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,32 @@ Active every session, with a handful of commands (see [Commands](#commands)). `/

Set the level for every new session with the `PONYTAIL_DEFAULT_MODE` env var (`lite`/`full`/`ultra`/`off`), or a `defaultMode` field in `~/.config/ponytail/config.json` (`%APPDATA%\ponytail\config.json` on Windows). The default is `full`.

### Status-line format (pi)

The pi extension shows `○ 🐴 ponytail: ⚡ FULL` (○ idle, ● active) whenever a mode is on. To drop the line entirely while keeping ponytail active, set `hideStatus: true` in `~/.config/ponytail/config.json` or `PONYTAIL_HIDE_STATUS=1`.

To change *what* the line shows instead, set `statusFormat` (or `PONYTAIL_STATUS_FORMAT`) to a template with these placeholders:

| Placeholder | Replaced with | Example |
|---|---|---|
| `{indicator}` | ○ when idle, ● (accent) when a turn is running | `○` |
| `{emoji}` | the 🐴 mascot | `🐴` |
| `{label}` | `ponytail:` (muted) | `ponytail:` |
| `{modeIcon}` | the level icon — 🌿 `lite`, ⚡ `full`, 🔥 `ultra` | `🔥` |
| `{mode}` | the level uppercased (text color) | `ULTRA` |

The default is `{indicator} {emoji} {label} {modeIcon} {mode}` (the built-in line above). Unknown placeholders pass through untouched, and `hideStatus` wins over `statusFormat` when both are set. Examples:

```json
{
"statusFormat": "{indicator} PT {mode}"
}
```

```sh
PONYTAIL_STATUS_FORMAT='{indicator} {mode} 🐴' ponytail…
```

While active, the ruleset is also injected into every subagent spawned via the Agent tool. To scope that to specific agent types (say, keep it off read-only search agents), set the `PONYTAIL_SUBAGENT_MATCHER` env var to a regex tested against the subagent's `agent_type`. It is unanchored and case-insensitive: `explore|general` matches either, `^general$` is exact, and plugin agent types look like `plugin:name`. Unset means inject into every subagent (the default); an invalid regex, or a subagent whose type the platform doesn't report, also falls back to injecting.

Cursor, Windsurf, Cline, GitHub Copilot Chat (the VS Code, JetBrains, and Visual Studio editor extension, not the standalone Copilot CLI covered under [Install](#install)), Aider, Kiro, Zed, CodeWhale, Swival, Qoder: copy the matching rules file from this repo ([`.cursor/rules/`](.cursor/rules/), [`.windsurf/rules/`](.windsurf/rules/), [`.clinerules/`](.clinerules/), [`.github/copilot-instructions.md`](.github/copilot-instructions.md), [`AGENTS.md`](AGENTS.md), [`.kiro/steering/`](.kiro/steering/), [`.qoder/rules/`](.qoder/rules/)).
Expand Down
16 changes: 16 additions & 0 deletions hooks/ponytail-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,21 @@ function getQuietStartup() {
}
}

// Status-line format template (see pi-extension syncStatus). PONYTAIL_STATUS_FORMAT
// takes precedence, else config.statusFormat. Undefined/empty -> built-in default,
// so existing setups keep their indicator unchanged. Placeholders are replaced in
// syncStatus: {indicator} {emoji} {label} {modeIcon} {mode}. Unknown placeholders
// are left untouched so a typo renders literally rather than silently swallowing text.
function getStatusFormat() {
const env = process.env.PONYTAIL_STATUS_FORMAT;
if (typeof env === 'string' && env.trim() !== '') return env;
try {
const config = JSON.parse(fs.readFileSync(getConfigPath(), 'utf8').replace(/^\uFEFF/, ''));
if (typeof config.statusFormat === 'string' && config.statusFormat.trim() !== '') return config.statusFormat;
} catch (_) {}
return null;
}

// Hide the status-bar indicator while keeping ponytail active (#324).
// PONYTAIL_HIDE_STATUS=1 (or any truthy value; 0/false/empty mean "don't hide")
// takes precedence, else config.hideStatus === true.
Expand Down Expand Up @@ -160,6 +175,7 @@ module.exports = {
getClaudeDir,
getHideStatus,
getQuietStartup,
getStatusFormat,
isShellSafe,
normalizeMode,
normalizeConfigMode,
Expand Down
26 changes: 24 additions & 2 deletions pi-extension/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const {
getDefaultMode,
getQuietStartup,
getHideStatus,
getStatusFormat,
normalizeMode,
normalizePersistedMode,
isDeactivationCommand,
Expand All @@ -17,6 +18,14 @@ const { getPonytailInstructions, filterSkillBodyForMode } = require("../hooks/po
export { filterSkillBodyForMode };
export const readDefaultMode = getDefaultMode;
export const readQuietStartup = getQuietStartup;
export const readStatusFormat = getStatusFormat;

// ponytail: default status-line format — kept here so tests can assert the exact
// baseline string and so a user clearing statusFormat still gets a real indicator.
// Placeholders: {indicator} (themed ○/●), {emoji} (🐴), {label} (themed "ponytail:"),
// {modeIcon} (🌿/⚡/🔥), {mode} (themed uppercased level). PONYTAIL: short on
// purpose; the footer already joins extension statuses with a space.
const DEFAULT_STATUS_FORMAT = '{indicator} {emoji} {label} {modeIcon} {mode}';

const RUNTIME_MODE_LIST = RUNTIME_MODES.join("|");
const PONYTAIL_COMMAND_DESCRIPTION = `Set mode: ${RUNTIME_MODE_LIST}. Commands: status, default <mode>`;
Expand Down Expand Up @@ -64,6 +73,7 @@ export default function ponytailExtension(pi) {
let currentMode = DEFAULT_MODE;
let configuredDefaultMode = getDefaultMode();
let hideStatus = getHideStatus();
let statusFormat = getStatusFormat() || DEFAULT_STATUS_FORMAT;
let isActive = false;
let lastCtx = null;

Expand All @@ -83,9 +93,20 @@ export default function ponytailExtension(pi) {
}
const levelIcons = { lite: "🌿", full: "⚡", ultra: "🔥" };
const icon = levelIcons[currentMode] || "";
const label = currentMode.toUpperCase();
const modeLabel = currentMode.toUpperCase();
const indicator = isActive ? theme.fg("accent", "●") : theme.fg("dim", "○");
c.ui.setStatus("ponytail", indicator + " 🐴 " + theme.fg("muted", "ponytail: ") + theme.fg("text", icon + " " + label));
const emoji = theme.fg("text", "🐴");
const label = theme.fg("muted", "ponytail:");
const modeIcon = theme.fg("text", icon);
const mode = theme.fg("text", modeLabel);
// ponytail: single replace pass; unknown placeholders pass through literally.
const text = statusFormat
.replaceAll('{indicator}', indicator)
.replaceAll('{emoji}', emoji)
.replaceAll('{label}', label)
.replaceAll('{modeIcon}', modeIcon)
.replaceAll('{mode}', mode);
c.ui.setStatus("ponytail", text);
}

const setMode = (mode, ctx) => {
Expand Down Expand Up @@ -184,6 +205,7 @@ export default function ponytailExtension(pi) {
const entries = ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || [];
configuredDefaultMode = getDefaultMode();
hideStatus = getHideStatus();
statusFormat = getStatusFormat() || DEFAULT_STATUS_FORMAT;
currentMode = resolveSessionMode(entries, configuredDefaultMode);
syncStatus(ctx);
if (!getQuietStartup()) {
Expand Down
51 changes: 51 additions & 0 deletions pi-extension/test/extension.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,57 @@ test("config.hideStatus hides the indicator but keeps ponytail active (#324)", a
assert.match(injected.systemPrompt, /PONYTAIL MODE ACTIVE/, "ruleset must still inject while status is hidden");
}));

test("status bar default format matches the documented baseline", async () => withTempConfig(async () => {
const { events } = createPiHarness();
const statusWrites = [];
const ctx = createCommandContext({
sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } }] },
ui: { notify() {}, setStatus: (key, text) => statusWrites.push({ key, text }), theme: { fg: (_c, t) => t } },
});

await events.get("session_start")({ reason: "resume" }, ctx);
await events.get("agent_start")({}, ctx);

assert.equal(statusWrites.at(-2).text, "○ 🐴 ponytail: 🔥 ULTRA", "idle baseline format");
assert.equal(statusWrites.at(-1).text, "● 🐴 ponytail: 🔥 ULTRA", "active baseline format");
}));

test("PONYTAIL_STATUS_FORMAT rewrites the status line", async () => withTempConfig(async () => {
process.env.PONYTAIL_STATUS_FORMAT = "{indicator} PT {mode}";
try {
const { events } = createPiHarness();
const statusWrites = [];
const ctx = createCommandContext({
sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "full" } }] },
ui: { notify() {}, setStatus: (key, text) => statusWrites.push({ key, text }), theme: { fg: (_c, t) => t } },
});

await events.get("session_start")({ reason: "resume" }, ctx);
await events.get("agent_start")({}, ctx);

assert.equal(statusWrites.at(-2).text, "○ PT FULL");
assert.equal(statusWrites.at(-1).text, "● PT FULL");
} finally {
delete process.env.PONYTAIL_STATUS_FORMAT;
}
}));

test("config.statusFormat rewrites the status line and unknown placeholders pass through", async () => withTempConfig(async () => {
if (process.env.PONYTAIL_STATUS_FORMAT !== undefined) delete process.env.PONYTAIL_STATUS_FORMAT;
mkdirSync(join(process.env.XDG_CONFIG_HOME, "ponytail"), { recursive: true });
writeFileSync(join(process.env.XDG_CONFIG_HOME, "ponytail", "config.json"), JSON.stringify({ statusFormat: "{mode} {emoji}{label}{unknown}" }));
const { events } = createPiHarness();
const statusWrites = [];
const ctx = createCommandContext({
sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "lite" } }] },
ui: { notify() {}, setStatus: (key, text) => statusWrites.push({ key, text }), theme: { fg: (_c, t) => t } },
});

await events.get("session_start")({ reason: "resume" }, ctx);

assert.equal(statusWrites.at(-1).text, "LITE 🐴ponytail:{unknown}");
}));

test("PONYTAIL_HIDE_STATUS=0 does not hide the indicator", async () => withTempConfig(async () => {
process.env.PONYTAIL_HIDE_STATUS = "0";
const { events } = createPiHarness();
Expand Down