From 533973700c7b20505ba538061450d4a37ab46072 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 22 Apr 2026 12:04:21 -0400 Subject: [PATCH] feat(#2538): add last: /cmd suffix to statusline (opt-in) (#2594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `statusline.show_last_command` config toggle (default: false) that appends ` │ last: /` 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 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 --- docs/CONFIGURATION.md | 1 + get-shit-done/bin/lib/config-schema.cjs | 1 + hooks/gsd-statusline.js | 142 +++++++++++++++++- .../enh-2538-statusline-last-command.test.cjs | 126 ++++++++++++++++ 4 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 tests/enh-2538-statusline-last-command.test.cjs diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index aacdbca5..d4434848 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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: /` suffix to the statusline showing the most recently invoked slash command. Opt-in; reads the active session transcript to extract the latest `` 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. diff --git a/get-shit-done/bin/lib/config-schema.cjs b/get-shit-done/bin/lib/config-schema.cjs index b7c1e7a2..d91ff6c1 100644 --- a/get-shit-done/bin/lib/config-schema.cjs +++ b/get-shit-done/bin/lib/config-schema.cjs @@ -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', diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index 11687b97..2840052a 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -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 + * /foo + * 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 = ''; + const idx = content.lastIndexOf(tagClose); + if (idx < 0) return null; + const openTag = ''; + 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 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(); diff --git a/tests/enh-2538-statusline-last-command.test.cjs b/tests/enh-2538-statusline-last-command.test.cjs new file mode 100644 index 00000000..a6e71bb6 --- /dev/null +++ b/tests/enh-2538-statusline-last-command.test.cjs @@ -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 + * /gsd-plan-phase, 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: '/gsd-plan-phase' } }) + '\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: /"', () => { + const transcript = + JSON.stringify({ type: 'user', message: { content: '/gsd-plan-phase' } }) + '\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: '/gsd-discuss-phase' } }) + '\n' + + JSON.stringify({ type: 'user', message: { content: '/gsd-plan-phase' } }) + '\n' + + JSON.stringify({ type: 'user', message: { content: '/gsd-execute-phase' } }) + '\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); +});