diff --git a/README.md b/README.md index c2e9b0e2..4900ce03 100644 --- a/README.md +++ b/README.md @@ -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/)). diff --git a/hooks/ponytail-config.js b/hooks/ponytail-config.js index 9ba7fdc2..51f5055d 100644 --- a/hooks/ponytail-config.js +++ b/hooks/ponytail-config.js @@ -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. @@ -160,6 +175,7 @@ module.exports = { getClaudeDir, getHideStatus, getQuietStartup, + getStatusFormat, isShellSafe, normalizeMode, normalizeConfigMode, diff --git a/pi-extension/index.js b/pi-extension/index.js index 84201ca9..62a55bf8 100644 --- a/pi-extension/index.js +++ b/pi-extension/index.js @@ -7,6 +7,7 @@ const { getDefaultMode, getQuietStartup, getHideStatus, + getStatusFormat, normalizeMode, normalizePersistedMode, isDeactivationCommand, @@ -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 `; @@ -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; @@ -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) => { @@ -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()) { diff --git a/pi-extension/test/extension.test.js b/pi-extension/test/extension.test.js index d466325b..572a3b9b 100644 --- a/pi-extension/test/extension.test.js +++ b/pi-extension/test/extension.test.js @@ -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();