feat(statusline): add opt-in context_position config for narrow terminals (#2937)

Extract composeStatusline() helper from duplicated inline template logic in
runStatusline() and renderStatusline(). Both call sites now route through the
helper, which accepts a position param ('end' | 'front', default 'end').

- 'end' (default) preserves byte-identical output to v1.38.x and earlier
- 'front' renders ctx immediately after model name, before the first │
- Invalid values silently coerce to 'end' at runtime (belt-and-suspenders;
  config-set rejects invalid values upfront via enum validator)

Adds statusline.context_position to VALID_CONFIG_KEYS in both CJS and TS
schemas, enum validator in config.cjs, docs row in CONFIGURATION.md,
and a changeset. Closes #2937.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-14 15:45:41 -04:00
parent 3f054b4a96
commit e0adba7e08
7 changed files with 188 additions and 10 deletions

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;