mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
Adds a `statusline.show_last_command` config toggle (default: false) that appends ` │ last: /<cmd>` to the statusline, showing the most recently invoked slash command in the current session. The suffix is derived by tailing the active Claude Code transcript (provided as transcript_path in the hook input) and extracting the last <command-name> tag. Reads only the final 256 KiB to stay cheap per render. Graceful degradation: missing transcript, no recorded command, unreadable config, or parse errors all silently omit the suffix without breaking the statusline. Closes #2538
This commit is contained in:
@@ -197,6 +197,7 @@ If `.planning/` is in `.gitignore`, `commit_docs` is automatically `false` regar
|
||||
|---------|------|---------|-------------|
|
||||
| `hooks.context_warnings` | boolean | `true` | Show context window usage warnings via context monitor hook |
|
||||
| `hooks.workflow_guard` | boolean | `false` | Warn when file edits happen outside GSD workflow context (advises using `/gsd-quick` or `/gsd-fast`) |
|
||||
| `statusline.show_last_command` | boolean | `false` | Append `last: /<cmd>` suffix to the statusline showing the most recently invoked slash command. Opt-in; reads the active session transcript to extract the latest `<command-name>` tag (closes #2538) |
|
||||
|
||||
The prompt injection guard hook (`gsd-prompt-guard.js`) is always active and cannot be disabled — it's a security feature, not a workflow toggle.
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ const VALID_CONFIG_KEYS = new Set([
|
||||
'workflow.inline_plan_threshold',
|
||||
'hooks.context_warnings',
|
||||
'hooks.workflow_guard',
|
||||
'statusline.show_last_command',
|
||||
'workflow.ui_review',
|
||||
'workflow.max_discuss_passes',
|
||||
'features.thinking_partner',
|
||||
|
||||
@@ -7,6 +7,92 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// --- Config + last-command readers ------------------------------------------
|
||||
|
||||
/**
|
||||
* Walk up from dir looking for .planning/config.json and return its parsed contents.
|
||||
* Returns {} if not found or unreadable.
|
||||
*/
|
||||
function readGsdConfig(dir) {
|
||||
const home = os.homedir();
|
||||
let current = dir;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const candidate = path.join(current, '.planning', 'config.json');
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(candidate, 'utf8')) || {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current || current === home) break;
|
||||
current = parent;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a dotted key path (e.g. 'statusline.show_last_command') in a config
|
||||
* object that may use either nested or flat keys.
|
||||
*/
|
||||
function getConfigValue(cfg, keyPath) {
|
||||
if (!cfg || typeof cfg !== 'object') return undefined;
|
||||
if (keyPath in cfg) return cfg[keyPath];
|
||||
const parts = keyPath.split('.');
|
||||
let cur = cfg;
|
||||
for (const p of parts) {
|
||||
if (cur == null || typeof cur !== 'object' || !(p in cur)) return undefined;
|
||||
cur = cur[p];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the most recently invoked slash command from a Claude Code JSONL
|
||||
* transcript file. Returns the command name (no leading slash) or null.
|
||||
*
|
||||
* Claude Code embeds slash invocations in user messages as
|
||||
* <command-name>/foo</command-name>
|
||||
* We scan lines from the end of the file, stopping at the first match.
|
||||
*/
|
||||
function readLastSlashCommand(transcriptPath) {
|
||||
if (!transcriptPath || typeof transcriptPath !== 'string') return null;
|
||||
let content;
|
||||
try {
|
||||
if (!fs.existsSync(transcriptPath)) return null;
|
||||
// Read only the tail — typical transcripts grow large. 256 KiB comfortably
|
||||
// covers dozens of recent turns while staying cheap per render.
|
||||
const stat = fs.statSync(transcriptPath);
|
||||
const MAX = 256 * 1024;
|
||||
const start = Math.max(0, stat.size - MAX);
|
||||
const fd = fs.openSync(transcriptPath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(stat.size - start);
|
||||
fs.readSync(fd, buf, 0, buf.length, start);
|
||||
content = buf.toString('utf8');
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
// Find the LAST occurrence — scan right-to-left via lastIndexOf on the tag.
|
||||
const tagClose = '</command-name>';
|
||||
const idx = content.lastIndexOf(tagClose);
|
||||
if (idx < 0) return null;
|
||||
const openTag = '<command-name>';
|
||||
const openIdx = content.lastIndexOf(openTag, idx);
|
||||
if (openIdx < 0) return null;
|
||||
let name = content.slice(openIdx + openTag.length, idx).trim();
|
||||
// Strip a leading slash if present, and any trailing arguments-on-same-line noise.
|
||||
if (name.startsWith('/')) name = name.slice(1);
|
||||
// Command names in Claude Code transcripts are plain identifiers like "gsd-plan-phase"
|
||||
// or namespaced like "plugin:skill". Reject anything with whitespace/newlines/control chars.
|
||||
if (!name || /[\s\\"<>]/.test(name) || name.length > 80) return null;
|
||||
return name;
|
||||
}
|
||||
|
||||
// --- GSD state reader -------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -240,6 +326,23 @@ function runStatusline() {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Last-slash-command suffix (opt-in via statusline.show_last_command, #2538).
|
||||
// Reads the active session transcript for the most recent <command-name> tag.
|
||||
// Failure here must never break the statusline — wrap the entire lookup.
|
||||
let lastCmdSuffix = '';
|
||||
try {
|
||||
const cfg = readGsdConfig(dir);
|
||||
if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
|
||||
const transcriptPath = data.transcript_path;
|
||||
const lastCmd = readLastSlashCommand(transcriptPath);
|
||||
if (lastCmd) {
|
||||
lastCmdSuffix = ` │ \x1b[2mlast: /${lastCmd}\x1b[0m`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Never break the statusline on config/transcript errors
|
||||
}
|
||||
|
||||
// Output
|
||||
const dirname = path.basename(dir);
|
||||
const middle = task
|
||||
@@ -249,9 +352,9 @@ function runStatusline() {
|
||||
: null;
|
||||
|
||||
if (middle) {
|
||||
process.stdout.write(`${gsdUpdate}\x1b[2m${model}\x1b[0m │ ${middle} │ \x1b[2m${dirname}\x1b[0m${ctx}`);
|
||||
process.stdout.write(`${gsdUpdate}\x1b[2m${model}\x1b[0m │ ${middle} │ \x1b[2m${dirname}\x1b[0m${ctx}${lastCmdSuffix}`);
|
||||
} else {
|
||||
process.stdout.write(`${gsdUpdate}\x1b[2m${model}\x1b[0m │ \x1b[2m${dirname}\x1b[0m${ctx}`);
|
||||
process.stdout.write(`${gsdUpdate}\x1b[2m${model}\x1b[0m │ \x1b[2m${dirname}\x1b[0m${ctx}${lastCmdSuffix}`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail - don't break statusline on parse errors
|
||||
@@ -260,6 +363,39 @@ function runStatusline() {
|
||||
}
|
||||
|
||||
// Export helpers for unit tests. Harmless when run as a script.
|
||||
module.exports = { readGsdState, parseStateMd, formatGsdState };
|
||||
module.exports = {
|
||||
readGsdState, parseStateMd, formatGsdState,
|
||||
readGsdConfig, getConfigValue, readLastSlashCommand,
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the statusline from an already-parsed hook input object. Exported for
|
||||
* testing without feeding stdin. Returns the rendered string.
|
||||
*/
|
||||
function renderStatusline(data) {
|
||||
const model = data.model?.display_name || 'Claude';
|
||||
const dir = data.workspace?.current_dir || process.cwd();
|
||||
const dirname = path.basename(dir);
|
||||
|
||||
let lastCmdSuffix = '';
|
||||
try {
|
||||
const cfg = readGsdConfig(dir);
|
||||
if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
|
||||
const lastCmd = readLastSlashCommand(data.transcript_path);
|
||||
if (lastCmd) {
|
||||
lastCmdSuffix = ` │ \x1b[2mlast: /${lastCmd}\x1b[0m`;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* swallow */ }
|
||||
|
||||
const gsdStateStr = formatGsdState(readGsdState(dir) || {});
|
||||
const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null;
|
||||
if (middle) {
|
||||
return `\x1b[2m${model}\x1b[0m │ ${middle} │ \x1b[2m${dirname}\x1b[0m${lastCmdSuffix}`;
|
||||
}
|
||||
return `\x1b[2m${model}\x1b[0m │ \x1b[2m${dirname}\x1b[0m${lastCmdSuffix}`;
|
||||
}
|
||||
|
||||
module.exports.renderStatusline = renderStatusline;
|
||||
|
||||
if (require.main === module) runStatusline();
|
||||
|
||||
126
tests/enh-2538-statusline-last-command.test.cjs
Normal file
126
tests/enh-2538-statusline-last-command.test.cjs
Normal file
@@ -0,0 +1,126 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Enhancement #2538 — statusline `last: /cmd` suffix.
|
||||
*
|
||||
* Asserts that:
|
||||
* - default (flag absent) output does NOT include "last:" text
|
||||
* - with statusline.show_last_command=true AND a transcript containing
|
||||
* <command-name>/gsd-plan-phase</command-name>, output includes "last: /gsd-plan-phase"
|
||||
* - a missing transcript_path does not throw and produces no "last:" suffix
|
||||
* - an existing transcript with no slash commands produces no "last:" suffix
|
||||
* - the config key is registered in the schema so /gsd-settings can surface it
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const statusline = require('../hooks/gsd-statusline.js');
|
||||
const { VALID_CONFIG_KEYS } = require('../get-shit-done/bin/lib/config-schema.cjs');
|
||||
|
||||
function makeProject({ flag, transcript }) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'enh-2538-'));
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
if (flag !== undefined) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'config.json'),
|
||||
JSON.stringify({ statusline: { show_last_command: flag } }),
|
||||
);
|
||||
}
|
||||
let transcriptPath = null;
|
||||
if (transcript !== undefined) {
|
||||
transcriptPath = path.join(dir, 'transcript.jsonl');
|
||||
fs.writeFileSync(transcriptPath, transcript);
|
||||
}
|
||||
return { dir, transcriptPath, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
function buildInput(dir, transcriptPath) {
|
||||
return {
|
||||
model: { display_name: 'Claude' },
|
||||
workspace: { current_dir: dir },
|
||||
session_id: 'test-session',
|
||||
transcript_path: transcriptPath,
|
||||
};
|
||||
}
|
||||
|
||||
test('config schema registers statusline.show_last_command', () => {
|
||||
assert.ok(
|
||||
VALID_CONFIG_KEYS.has('statusline.show_last_command'),
|
||||
'statusline.show_last_command must be in VALID_CONFIG_KEYS',
|
||||
);
|
||||
});
|
||||
|
||||
test('default (flag absent) output has no "last:" suffix', () => {
|
||||
const transcript =
|
||||
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-plan-phase</command-name>' } }) + '\n';
|
||||
const { dir, transcriptPath, cleanup } = makeProject({ transcript });
|
||||
try {
|
||||
const out = statusline.renderStatusline(buildInput(dir, transcriptPath));
|
||||
assert.ok(!out.includes('last:'), `expected no "last:" in output; got: ${out}`);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('flag=true with recorded command yields "last: /<cmd>"', () => {
|
||||
const transcript =
|
||||
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-plan-phase</command-name>' } }) + '\n' +
|
||||
JSON.stringify({ type: 'assistant', message: { content: 'ok' } }) + '\n';
|
||||
const { dir, transcriptPath, cleanup } = makeProject({ flag: true, transcript });
|
||||
try {
|
||||
const out = statusline.renderStatusline(buildInput(dir, transcriptPath));
|
||||
assert.ok(out.includes('last: /gsd-plan-phase'), `expected "last: /gsd-plan-phase" in output; got: ${out}`);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('flag=true picks the MOST RECENT command when multiple are present', () => {
|
||||
const transcript =
|
||||
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-discuss-phase</command-name>' } }) + '\n' +
|
||||
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-plan-phase</command-name>' } }) + '\n' +
|
||||
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-execute-phase</command-name>' } }) + '\n';
|
||||
const { dir, transcriptPath, cleanup } = makeProject({ flag: true, transcript });
|
||||
try {
|
||||
const out = statusline.renderStatusline(buildInput(dir, transcriptPath));
|
||||
assert.ok(out.includes('last: /gsd-execute-phase'), `expected most-recent "gsd-execute-phase"; got: ${out}`);
|
||||
assert.ok(!out.includes('last: /gsd-discuss-phase'), `should not show stale command; got: ${out}`);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('flag=true with missing transcript_path does not throw and omits suffix', () => {
|
||||
const { dir, cleanup } = makeProject({ flag: true });
|
||||
try {
|
||||
let out;
|
||||
assert.doesNotThrow(() => {
|
||||
out = statusline.renderStatusline(buildInput(dir, undefined));
|
||||
});
|
||||
assert.ok(!out.includes('last:'), `expected no "last:" suffix when transcript missing; got: ${out}`);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('flag=true with transcript lacking command tags omits suffix', () => {
|
||||
const transcript =
|
||||
JSON.stringify({ type: 'user', message: { content: 'just a plain prompt' } }) + '\n';
|
||||
const { dir, transcriptPath, cleanup } = makeProject({ flag: true, transcript });
|
||||
try {
|
||||
const out = statusline.renderStatusline(buildInput(dir, transcriptPath));
|
||||
assert.ok(!out.includes('last:'), `expected no "last:" suffix with no commands; got: ${out}`);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('readLastSlashCommand returns null for nonexistent paths', () => {
|
||||
assert.strictEqual(statusline.readLastSlashCommand('/nonexistent/path.jsonl'), null);
|
||||
assert.strictEqual(statusline.readLastSlashCommand(null), null);
|
||||
assert.strictEqual(statusline.readLastSlashCommand(undefined), null);
|
||||
});
|
||||
Reference in New Issue
Block a user