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
12 changes: 11 additions & 1 deletion hooks/ponytail-activate.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@

const fs = require('fs');
const path = require('path');
const { getDefaultMode, getClaudeDir, isShellSafe } = require('./ponytail-config');
const { getDefaultMode, getClaudeDir, getHideStatus, isShellSafe } = require('./ponytail-config');
const { getPonytailInstructions } = require('./ponytail-instructions');
const {
clearMode,
isCodex,
isCopilot,
setHidden,
setMode,
writeHookOutput,
} = require('./ponytail-runtime');
Expand All @@ -38,6 +39,15 @@ try {
// Silent fail -- flag is best-effort, don't block the hook
}

// 1b. Reflect hideStatus into a marker the statusline scripts can stat (#659).
// getHideStatus() reads PONYTAIL_HIDE_STATUS or config.hideStatus; the marker is
// rewritten every session start so toggling the setting takes effect next session.
try {
setHidden(getHideStatus());
} catch (e) {
// Silent fail — the badge is cosmetic, never block session start over it.
}

// 2. Emit the ponytail ruleset, filtered to the active intensity level.
let output = getPonytailInstructions(mode);

Expand Down
22 changes: 22 additions & 0 deletions hooks/ponytail-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const os = require('os');
const { getClaudeDir, getConfigDir } = require('./ponytail-config');

const STATE_FILE = '.ponytail-active';
const HIDDEN_FILE = '.ponytail-hidden';

// ponytail: VS Code Copilot never sets COPILOT_PLUGIN_DATA — it only injects
// CLAUDE_PLUGIN_ROOT, pointed at an install path under .vscode/agent-plugins/
Expand All @@ -29,12 +30,32 @@ if (isCopilot) stateDir = process.env.COPILOT_PLUGIN_DATA || getClaudeDir();
if (isQoder) stateDir = path.join(os.homedir(), '.qoder');

const statePath = path.join(stateDir, STATE_FILE);
const hiddenPath = path.join(stateDir, HIDDEN_FILE);

function setMode(mode) {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
fs.writeFileSync(statePath, mode);
}

// Hide the statusline badge without deactivating ponytail (#659). The statusline
// scripts are shell/PowerShell that only stat a file, so instead of teaching them
// to parse config, activate resolves getHideStatus() once and drops (or clears)
// this marker next to the mode flag. Rewritten every session start, so unsetting
// PONYTAIL_HIDE_STATUS brings the badge back on its own.
function setHidden(hidden) {
try {
if (hidden) {
fs.mkdirSync(path.dirname(hiddenPath), { recursive: true });
fs.writeFileSync(hiddenPath, '');
} else {
fs.unlinkSync(hiddenPath);
}
} catch (e) {
// best-effort: ENOENT on clear is fine, and the badge is cosmetic — never
// block the hook over it.
}
}

function clearMode() {
try { fs.unlinkSync(statePath); } catch (e) {}
}
Expand Down Expand Up @@ -95,6 +116,7 @@ module.exports = {
isCopilot,
isQoder,
readMode,
setHidden,
setMode,
writeHookOutput,
};
4 changes: 4 additions & 0 deletions hooks/ponytail-statusline.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ $Flag = Join-Path $ClaudeDir ".ponytail-active"
if (-not (Test-Path $Flag)) {
exit 0
}
# hideStatus: activate drops this marker when the badge is silenced (#659)
if (Test-Path (Join-Path $ClaudeDir ".ponytail-hidden")) {
exit 0
}

$Mode = ""
try {
Expand Down
5 changes: 4 additions & 1 deletion hooks/ponytail-statusline.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
#!/usr/bin/env bash
# CLAUDE_CONFIG_DIR overrides ~/.claude, matching where the hooks write the flag (issue #34)
flag="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.ponytail-active"
dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
flag="$dir/.ponytail-active"
[ -f "$flag" ] || exit 0
# hideStatus: activate drops this marker when the badge is silenced (#659)
[ -f "$dir/.ponytail-hidden" ] && exit 0

mode=$(head -n1 "$flag" | tr -d '[:space:]')

Expand Down
1 change: 1 addition & 0 deletions scripts/uninstall.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function removeIfExists(filePath, label) {
}

removeIfExists(path.join(getClaudeDir(), '.ponytail-active'), 'mode flag');
removeIfExists(path.join(getClaudeDir(), '.ponytail-hidden'), 'hidden-badge marker');
removeIfExists(getConfigPath(), 'config file');

const settingsPath = path.join(getClaudeDir(), 'settings.json');
Expand Down
82 changes: 82 additions & 0 deletions tests/statusline-hide.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env node
// #659 — hideStatus parity for the Claude Code statusline badge.
//
// getHideStatus() lived in the shared hooks/ponytail-config.js but only
// pi-extension read it; the native Claude/Codex statusline scripts ignored it.
// The fix: ponytail-activate.js drops a `.ponytail-hidden` marker next to the
// mode flag when hideStatus resolves truthy, and the statusline scripts bail
// when they see it. This test proves the whole round-trip via the real hook.

const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');

const root = path.join(__dirname, '..');

// The statusline script under test is bash; skip on hosts without it rather
// than fail (Windows has its own ponytail-statusline.ps1, unit-tested elsewhere).
const bashOk = spawnSync('bash', ['-c', 'true'], { encoding: 'utf8' }).status === 0;

function activate(env) {
return spawnSync(process.execPath, [path.join(root, 'hooks', 'ponytail-activate.js')], {
env: { ...process.env, ...env },
encoding: 'utf8',
});
}
function statusline(env) {
return spawnSync('bash', [path.join(root, 'hooks', 'ponytail-statusline.sh')], {
env: { ...process.env, ...env },
encoding: 'utf8',
});
}

// Keep the base env clean so activate takes the native-Claude branch.
for (const k of ['CLAUDE_CONFIG_DIR', 'PLUGIN_DATA', 'COPILOT_PLUGIN_DATA',
'QODER_SESSION_ID', 'PONYTAIL_HIDE_STATUS', 'PONYTAIL_DEFAULT_MODE']) {
delete process.env[k];
}

const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-hide-'));
process.on('exit', () => fs.rmSync(temp, { recursive: true, force: true }));

const home = path.join(temp, 'home');
fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
const hiddenMarker = path.join(home, '.claude', '.ponytail-hidden');
const base = { HOME: home, USERPROFILE: home };

// 1. Default: no hideStatus → no marker → badge shows.
let r = activate(base);
assert.equal(r.status, 0, r.stderr);
assert.equal(fs.existsSync(hiddenMarker), false, 'no marker when hideStatus is off');
if (bashOk) {
assert.match(statusline(base).stdout, /PONYTAIL/, 'badge must show by default');
}

// 2. PONYTAIL_HIDE_STATUS=1 → activate writes the marker → statusline prints nothing.
r = activate({ ...base, PONYTAIL_HIDE_STATUS: '1' });
assert.equal(r.status, 0, r.stderr);
assert.equal(fs.existsSync(hiddenMarker), true, 'marker written when hideStatus is on');
if (bashOk) {
assert.equal(statusline(base).stdout, '', 'badge must be hidden when marker is present');
}

// 3. config.hideStatus (no env var) is honored too — proves the shared resolver
// is used, not just the env shortcut.
fs.mkdirSync(path.join(temp, 'cfg', 'ponytail'), { recursive: true });
fs.writeFileSync(path.join(temp, 'cfg', 'ponytail', 'config.json'),
JSON.stringify({ hideStatus: true }));
r = activate({ ...base, XDG_CONFIG_HOME: path.join(temp, 'cfg') });
assert.equal(r.status, 0, r.stderr);
assert.equal(fs.existsSync(hiddenMarker), true, 'config.hideStatus must write the marker');

// 4. Toggling it back off rewrites state: marker cleared, badge returns.
r = activate(base);
assert.equal(r.status, 0, r.stderr);
assert.equal(fs.existsSync(hiddenMarker), false, 'marker cleared when hideStatus is unset');
if (bashOk) {
assert.match(statusline(base).stdout, /PONYTAIL/, 'badge returns after unsetting hideStatus');
}

console.log('statusline-hide.test.js: OK' + (bashOk ? '' : ' (bash-dependent asserts skipped)'));
5 changes: 5 additions & 0 deletions tests/uninstall.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ fs.mkdirSync(claudeDir, { recursive: true });
const flagPath = path.join(claudeDir, '.ponytail-active');
fs.writeFileSync(flagPath, 'full');

// #659: uninstall must also clear the hide-badge marker activate may have written.
const hiddenPath = path.join(claudeDir, '.ponytail-hidden');
fs.writeFileSync(hiddenPath, '');

const configDir = path.join(temp, 'config-home', 'ponytail');
fs.mkdirSync(configDir, { recursive: true });
const configPath = path.join(configDir, 'config.json');
Expand All @@ -46,6 +50,7 @@ const env = {
let result = runUninstall(env);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.existsSync(flagPath), false, 'mode flag must be removed');
assert.equal(fs.existsSync(hiddenPath), false, 'hide-badge marker must be removed (#659)');
assert.equal(fs.existsSync(configPath), false, 'config file must be removed');

const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
Expand Down
Loading