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:
5
.changeset/2937-statusline-context-position.md
Normal file
5
.changeset/2937-statusline-context-position.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
149
tests/enh-2937-statusline-context-position.test.cjs
Normal file
149
tests/enh-2937-statusline-context-position.test.cjs
Normal 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user