Merge pull request #3515 from gsd-build/feat/2937-enhancement-statusline-opt-in-context-po

feat(statusline): opt-in context_position config for narrow terminals
This commit is contained in:
Tom Boucher
2026-05-14 16:41:38 -04:00
committed by GitHub
7 changed files with 216 additions and 10 deletions

View File

@@ -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.

View File

@@ -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: /<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) |
| `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.

View File

@@ -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',

View File

@@ -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))) {

View File

@@ -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 <command-name> 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;

View File

@@ -63,6 +63,7 @@ export const VALID_CONFIG_KEYS: ReadonlySet<string> = 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',

View File

@@ -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);
}
});