diff --git a/.changeset/2937-statusline-context-position.md b/.changeset/2937-statusline-context-position.md new file mode 100644 index 000000000..63d5e352b --- /dev/null +++ b/.changeset/2937-statusline-context-position.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 3515 +--- +**Context-window meter position is now configurable via `statusline.context_position`** — set `"front"` to render the meter immediately after the model name (useful in narrow terminals where the right edge is clipped); the default `"end"` preserves the existing byte-identical output. Invalid values at `config-set` time are hard-rejected by the enum validator; at hook runtime an invalid/stale config silently falls back to `"end"` so the statusline is never broken. Closes #2937. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 581dcc293..e94f61b63 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -361,6 +361,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) | +| `statusline.context_position` | string | `"end"` | Position of the context-window meter. `"end"` (default) renders at line tail; `"front"` renders immediately after the model name so the meter stays visible in narrow terminals. Closes #2937 | 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 c002c251e..38c33e3b9 100644 --- a/get-shit-done/bin/lib/config-schema.cjs +++ b/get-shit-done/bin/lib/config-schema.cjs @@ -61,6 +61,7 @@ const VALID_CONFIG_KEYS = new Set([ 'hooks.workflow_guard', 'workflow.context_coverage_gate', 'statusline.show_last_command', + 'statusline.context_position', 'workflow.ui_review', 'workflow.max_discuss_passes', 'features.thinking_partner', diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index 0463d0914..cc93e3ed0 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -450,6 +450,12 @@ function cmdConfigSet(cwd, keyPath, value, raw) { error(`Invalid workflow.human_verify_mode '${value}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`); } + // Context position enum validation (#2937) + const VALID_CONTEXT_POSITIONS = ['front', 'end']; + if (keyPath === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) { + error(`Invalid statusline.context_position '${value}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`); + } + // Fallow scope + profile enum validation (#3424) const VALID_FALLOW_SCOPES = ['phase', 'repo']; if (keyPath === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) { diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index 33821c19f..250ca2e29 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -419,10 +419,11 @@ function runStatusline() { } catch (e) {} } - // Last-slash-command suffix (opt-in via statusline.show_last_command, #2538). + // Last-slash-command suffix and context_position config (#2538, #2937). // Reads the active session transcript for the most recent tag. // Failure here must never break the statusline — wrap the entire lookup. let lastCmdSuffix = ''; + let position = 'end'; try { const cfg = readGsdConfig(dir); if (getConfigValue(cfg, 'statusline.show_last_command') === true) { @@ -432,6 +433,8 @@ function runStatusline() { lastCmdSuffix = ` │ \x1b[2mlast: /${lastCmd}\x1b[0m`; } } + const cfgPos = getConfigValue(cfg, 'statusline.context_position'); + if (cfgPos != null) position = cfgPos; } catch (e) { // Never break the statusline on config/transcript errors } @@ -444,21 +447,61 @@ function runStatusline() { ? `\x1b[2m${gsdStateStr}\x1b[0m` : null; - if (middle) { - 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}${lastCmdSuffix}`); - } + process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, position })); } catch (e) { // Silent fail - don't break statusline on parse errors } }); } +// --- Layout composer -------------------------------------------------------- + +/** + * Compose the statusline string from pre-built segments. + * + * @param {object} opts + * @param {string} [opts.gsdUpdate=''] - leading update/stale-hooks warning (already formatted) + * @param {string} opts.model - model display name (plain text; dim styling applied here) + * @param {string} [opts.ctx=''] - context-window meter segment (empty string = absent) + * @param {string|null} [opts.middle=null] - middle segment (todo task or GSD state), null = absent + * @param {string} opts.dirname - project directory basename (dim styling applied here) + * @param {string} [opts.lastCmdSuffix=''] - last-command suffix, e.g. ' │ last: /foo' + * @param {'end'|'front'} [opts.position='end'] + * - 'end' (default): ctx appended after dirname — preserved byte-for-byte + * - 'front': ctx immediately after model name so the meter stays visible in narrow terminals + * + * Invalid position values are silently coerced to 'end' — config-set schema rejects + * invalid values upfront; runtime fallback defends against stale/corrupt configs + * without breaking the statusline. + */ +function composeStatusline({ + gsdUpdate = '', + model, + ctx = '', + middle = null, + dirname, + lastCmdSuffix = '', + position = 'end', +} = {}) { + const modelSeg = `\x1b[2m${model}\x1b[0m`; + const dirSeg = `\x1b[2m${dirname}\x1b[0m`; + // Coerce invalid values to 'end' (belt-and-suspenders; see JSDoc above) + const pos = position === 'front' ? 'front' : 'end'; + + if (pos === 'front') { + if (middle) return `${gsdUpdate}${modelSeg}${ctx} │ ${middle} │ ${dirSeg}${lastCmdSuffix}`; + return `${gsdUpdate}${modelSeg}${ctx} │ ${dirSeg}${lastCmdSuffix}`; + } + // 'end' — preserved byte-for-byte relative to original inline templates + if (middle) return `${gsdUpdate}${modelSeg} │ ${middle} │ ${dirSeg}${ctx}${lastCmdSuffix}`; + return `${gsdUpdate}${modelSeg} │ ${dirSeg}${ctx}${lastCmdSuffix}`; +} + // Export helpers for unit tests. Harmless when run as a script. module.exports = { readGsdState, parseStateMd, formatGsdState, readGsdConfig, getConfigValue, readLastSlashCommand, + composeStatusline, }; /** @@ -471,6 +514,7 @@ function renderStatusline(data) { const dirname = path.basename(dir); let lastCmdSuffix = ''; + let position = 'end'; try { const cfg = readGsdConfig(dir); if (getConfigValue(cfg, 'statusline.show_last_command') === true) { @@ -479,14 +523,13 @@ function renderStatusline(data) { lastCmdSuffix = ` │ \x1b[2mlast: /${lastCmd}\x1b[0m`; } } + const cfgPos = getConfigValue(cfg, 'statusline.context_position'); + if (cfgPos != null) position = cfgPos; } 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}`; + return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, position }); } module.exports.renderStatusline = renderStatusline; diff --git a/sdk/src/query/config-schema.ts b/sdk/src/query/config-schema.ts index c294c37e2..bbfb6169e 100644 --- a/sdk/src/query/config-schema.ts +++ b/sdk/src/query/config-schema.ts @@ -63,6 +63,7 @@ export const VALID_CONFIG_KEYS: ReadonlySet = new Set([ 'hooks.workflow_guard', 'workflow.context_coverage_gate', 'statusline.show_last_command', + 'statusline.context_position', 'workflow.ui_review', 'workflow.max_discuss_passes', 'features.thinking_partner', diff --git a/tests/enh-2937-statusline-context-position.test.cjs b/tests/enh-2937-statusline-context-position.test.cjs new file mode 100644 index 000000000..d8454923c --- /dev/null +++ b/tests/enh-2937-statusline-context-position.test.cjs @@ -0,0 +1,149 @@ +'use strict'; + +/** + * Enhancement #2937 — statusline opt-in `context_position` config. + * + * Asserts that: + * - VALID_CONFIG_KEYS registers statusline.context_position (parity guard) + * - Default (no config) renders ctx at tail — "end" layout + * - Explicit "end" is byte-identical to default (regression guard) + * - Explicit "front" puts ctx after model, before first " │ " + * - Empty ctx with "front" leaves no stray separator + * - Invalid value (e.g. "middle") silently falls back to "end" at runtime + * - gsdUpdate warning stays leftmost in both "front" and "end" modes + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { composeStatusline } = require('../hooks/gsd-statusline.js'); +const { VALID_CONFIG_KEYS } = require('../get-shit-done/bin/lib/config-schema.cjs'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +// ── Parity guard ───────────────────────────────────────────────────────────── + +test('config schema registers statusline.context_position', () => { + assert.ok( + VALID_CONFIG_KEYS.has('statusline.context_position'), + 'statusline.context_position must be in VALID_CONFIG_KEYS', + ); +}); + +// ── Default / "end" layout ─────────────────────────────────────────────────── + +test('default (no position arg) renders ctx at tail — end layout', () => { + const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; + const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx }); + // ctx should appear after dirname, not before first │ + const dirIdx = out.indexOf('myproject'); + const ctxIdx = out.indexOf(ctx); + assert.ok(ctxIdx > dirIdx, `ctx should be after dirname; got: ${out}`); +}); + +test('explicit "end" is byte-identical to default', () => { + const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; + const args = { model: 'Claude', dirname: 'myproject', ctx }; + const defaultOut = composeStatusline(args); + const endOut = composeStatusline({ ...args, position: 'end' }); + assert.strictEqual(endOut, defaultOut, 'explicit "end" must equal default output'); +}); + +test('"end" with middle segment places ctx after dirname', () => { + const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; + const out = composeStatusline({ model: 'Claude', ctx, middle: 'doing work', dirname: 'proj', position: 'end' }); + const dirIdx = out.indexOf('proj'); + const ctxIdx = out.indexOf(ctx); + assert.ok(ctxIdx > dirIdx, `ctx should be after dirname in end mode; got: ${out}`); +}); + +// ── "front" layout ─────────────────────────────────────────────────────────── + +test('"front" puts ctx after model name, before first │', () => { + const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; + const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx, position: 'front' }); + const firstPipe = out.indexOf(' │ '); + const ctxIdx = out.indexOf(ctx); + assert.ok(ctxIdx !== -1, `ctx should appear in output; got: ${out}`); + assert.ok(ctxIdx < firstPipe, `ctx should come before first │ in front mode; got: ${out}`); +}); + +test('"front" with middle segment: ctx after model, before first │', () => { + const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; + const out = composeStatusline({ model: 'Claude', ctx, middle: 'doing work', dirname: 'proj', position: 'front' }); + const firstPipe = out.indexOf(' │ '); + const ctxIdx = out.indexOf(ctx); + assert.ok(ctxIdx < firstPipe, `ctx must precede first │; got: ${out}`); +}); + +// ── Empty ctx ──────────────────────────────────────────────────────────────── + +test('empty ctx + "front" renders no stray separator', () => { + const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx: '', position: 'front' }); + // Should not have double-separator or leading │ + assert.ok(!out.includes(' │ │ '), `stray separator found; got: ${out}`); + // Should still contain the single separator between model area and dirname + assert.ok(out.includes(' │ '), `expected at least one separator; got: ${out}`); +}); + +test('empty ctx + "end" renders no stray separator', () => { + const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx: '', position: 'end' }); + assert.ok(!out.includes(' │ │ '), `stray separator found; got: ${out}`); +}); + +// ── Invalid value fallback ─────────────────────────────────────────────────── + +test('invalid position value silently falls back to "end" layout', () => { + const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; + const invalid = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx, position: 'middle' }); + const end = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx, position: 'end' }); + assert.strictEqual(invalid, end, `invalid position should produce same output as "end"; got: ${invalid}`); +}); + +test('invalid position "banana" silently falls back to "end"', () => { + const ctx = ' \x1b[33m██████░░░░ 60%\x1b[0m'; + const invalid = composeStatusline({ model: 'Claude', dirname: 'proj', ctx, position: 'banana' }); + const end = composeStatusline({ model: 'Claude', dirname: 'proj', ctx, position: 'end' }); + assert.strictEqual(invalid, end, `invalid "banana" should fall back to "end"; got: ${invalid}`); +}); + +// ── gsdUpdate leftmost invariant ───────────────────────────────────────────── + +test('gsdUpdate warning is leftmost in "end" mode', () => { + const gsdUpdate = '\x1b[33m⬆ /gsd:update\x1b[0m │ '; + const out = composeStatusline({ gsdUpdate, model: 'Claude', dirname: 'proj', position: 'end' }); + assert.ok(out.startsWith(gsdUpdate), `gsdUpdate should be leftmost in end mode; got: ${out}`); +}); + +test('gsdUpdate warning is leftmost in "front" mode', () => { + const gsdUpdate = '\x1b[33m⬆ /gsd:update\x1b[0m │ '; + const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; + const out = composeStatusline({ gsdUpdate, model: 'Claude', dirname: 'proj', ctx, position: 'front' }); + assert.ok(out.startsWith(gsdUpdate), `gsdUpdate should be leftmost in front mode; got: ${out}`); +}); + +// ── CLI write-path enforcement (config-set rejects invalid enum) ───────────── +// Locked design: hard reject at config-set time AND silent fallback at runtime. +// The runtime fallback is covered by the "Invalid position value silently falls +// back" tests above. This test covers the other half — that the CLI write path +// actually refuses to persist an invalid value in the first place. + +test('config-set rejects invalid statusline.context_position', () => { + const tmpDir = createTempProject(); + try { + const r = runGsdTools( + ['config-set', 'statusline.context_position', 'middle'], + tmpDir, + ); + assert.equal( + r.success, + false, + `config-set should exit non-zero on invalid enum; got success=${r.success}, output=${r.output}`, + ); + assert.ok( + /statusline\.context_position|Invalid/i.test(r.error), + `stderr must reference key or "Invalid"; got: ${r.error}`, + ); + } finally { + cleanup(tmpDir); + } +});