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:
121
tests/enh-2937-statusline-context-position.test.cjs
Normal file
121
tests/enh-2937-statusline-context-position.test.cjs
Normal file
@@ -0,0 +1,121 @@
|
||||
'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');
|
||||
|
||||
// ── 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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user