fix(tui): /statusbar top = inline above input, not row 0 of the screen

'top' and 'bottom' are positions relative to the input row, not the alt
screen viewport:

- top (default) → inline above the input, where the bar originally lived
  (what 'on' used to mean)
- bottom → below the input, pinned to the last row
- off → hidden

Drops the literal top-of-screen placement; 'on' is kept as a backward-
compat alias that resolves to 'top' at both the config layer
(normalizeStatusBar, _coerce_statusbar) and the slash command.
This commit is contained in:
Brooklyn Nicholson 2026-04-22 13:55:40 -05:00
parent d55a17bd82
commit ea32364c96
7 changed files with 60 additions and 41 deletions

View file

@ -455,18 +455,22 @@ def _write_config_key(key_path: str, value):
_save_cfg(cfg)
# Legacy configs stored display.tui_statusbar as a bool. Coerce both bool and
# string forms to the string enum so rollouts don't require a manual migration.
# Legacy configs stored display.tui_statusbar as a bool; a short-lived
# intermediate wrote 'on'. Both forms map to 'top' — the inline position
# above the input where the bar originally lived — so users don't need to
# migrate by hand.
def _coerce_statusbar(raw) -> str:
if raw is True:
return "on"
return "top"
if raw is False:
return "off"
if isinstance(raw, str):
s = raw.strip().lower()
if s in {"on", "off", "bottom", "top"}:
if s == "on":
return "top"
if s in {"off", "top", "bottom"}:
return s
return "on"
return "top"
def _load_reasoning_config() -> dict | None:
@ -2533,10 +2537,12 @@ def _(rid, params: dict) -> dict:
raw = str(value or "").strip().lower()
cfg0 = _load_cfg()
d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {}
current = _coerce_statusbar(d0.get("tui_statusbar", "on"))
current = _coerce_statusbar(d0.get("tui_statusbar", "top"))
if raw in ("", "toggle"):
nv = "on" if current == "off" else "off"
elif raw in ("on", "off", "bottom", "top"):
nv = "top" if current == "off" else "off"
elif raw == "on":
nv = "top"
elif raw in ("off", "top", "bottom"):
nv = raw
else:
return _err(rid, 4002, f"unknown statusbar value: {value}")
@ -2659,7 +2665,7 @@ def _(rid, params: dict) -> dict:
on = bool(_load_cfg().get("display", {}).get("tui_compact", False))
return _ok(rid, {"value": "on" if on else "off"})
if key == "statusbar":
raw = _load_cfg().get("display", {}).get("tui_statusbar", "on")
raw = _load_cfg().get("display", {}).get("tui_statusbar", "top")
return _ok(rid, {"value": _coerce_statusbar(raw)})
if key == "mtime":
cfg_path = _hermes_home / "config.yaml"

View file

@ -40,6 +40,16 @@ describe('applyDisplay', () => {
expect(s.streaming).toBe(false)
})
it('coerces legacy true + "on" alias to top', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: { tui_statusbar: true as unknown as 'on' } } }, setBell)
expect($uiState.get().statusBar).toBe('top')
applyDisplay({ config: { display: { tui_statusbar: 'on' } } }, setBell)
expect($uiState.get().statusBar).toBe('top')
})
it('applies v1 parity defaults when display fields are missing', () => {
const setBell = vi.fn()
@ -50,7 +60,7 @@ describe('applyDisplay', () => {
expect(s.inlineDiffs).toBe(true)
expect(s.showCost).toBe(false)
expect(s.showReasoning).toBe(false)
expect(s.statusBar).toBe('on')
expect(s.statusBar).toBe('top')
expect(s.streaming).toBe(true)
})
@ -77,22 +87,22 @@ describe('applyDisplay', () => {
})
describe('normalizeStatusBar', () => {
it('maps legacy bool to on/off', () => {
expect(normalizeStatusBar(true)).toBe('on')
it('maps legacy bool + on alias to top/off', () => {
expect(normalizeStatusBar(true)).toBe('top')
expect(normalizeStatusBar(false)).toBe('off')
expect(normalizeStatusBar('on')).toBe('top')
})
it('passes through the new string enum', () => {
expect(normalizeStatusBar('on')).toBe('on')
it('passes through the canonical enum', () => {
expect(normalizeStatusBar('off')).toBe('off')
expect(normalizeStatusBar('bottom')).toBe('bottom')
expect(normalizeStatusBar('top')).toBe('top')
expect(normalizeStatusBar('bottom')).toBe('bottom')
})
it('defaults missing/unknown values to on', () => {
expect(normalizeStatusBar(undefined)).toBe('on')
expect(normalizeStatusBar(null)).toBe('on')
expect(normalizeStatusBar('sideways')).toBe('on')
expect(normalizeStatusBar(42)).toBe('on')
it('defaults missing/unknown values to top', () => {
expect(normalizeStatusBar(undefined)).toBe('top')
expect(normalizeStatusBar(null)).toBe('top')
expect(normalizeStatusBar('sideways')).toBe('top')
expect(normalizeStatusBar(42)).toBe('top')
})
})

View file

@ -27,7 +27,7 @@ export interface StateSetter<T> {
(value: SetStateAction<T>): void
}
export type StatusBarMode = 'bottom' | 'off' | 'on' | 'top'
export type StatusBarMode = 'bottom' | 'off' | 'top'
export interface SelectionApi {
clearSelection: () => void

View file

@ -8,10 +8,10 @@ import type {
SessionSteerResponse,
SessionUndoResponse
} from '../../../gatewayTypes.js'
import type { StatusBarMode } from '../../interfaces.js'
import { writeOsc52Clipboard } from '../../../lib/osc52.js'
import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js'
import type { DetailsMode, Msg, PanelSection } from '../../../types.js'
import type { StatusBarMode } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
@ -306,24 +306,28 @@ export const coreCommands: SlashCommand[] = [
{
aliases: ['sb'],
help: 'status bar position (on|off|bottom|top)',
help: 'status bar position (on|off|top|bottom)',
name: 'statusbar',
run: (arg, ctx) => {
const mode = arg.trim().toLowerCase()
const current = ctx.ui.statusBar
// No-arg / `toggle` flips visibility while preserving the last
// explicit position: off → on (inline default), any-visible → off.
// 'on' is a legacy alias for 'top' — the inline position above the
// input where the bar originally lived. No-arg / `toggle` flips
// visibility and defaults to 'top' when reappearing.
const next: null | StatusBarMode =
mode === '' || mode === 'toggle'
? current === 'off'
? 'on'
? 'top'
: 'off'
: mode === 'on' || mode === 'off' || mode === 'bottom' || mode === 'top'
? mode
: null
: mode === 'on' || mode === 'top'
? 'top'
: mode === 'off' || mode === 'bottom'
? mode
: null
if (next === null) {
return ctx.transcript.sys('usage: /statusbar [on|off|bottom|top|toggle]')
return ctx.transcript.sys('usage: /statusbar [on|off|top|bottom|toggle]')
}
patchUiState({ statusBar: next })

View file

@ -16,7 +16,7 @@ const buildUiState = (): UiState => ({
showReasoning: false,
sid: null,
status: 'summoning hermes…',
statusBar: 'on',
statusBar: 'top',
streaming: true,
theme: DEFAULT_THEME,
usage: ZERO

View file

@ -14,17 +14,18 @@ import type { StatusBarMode } from './interfaces.js'
import { turnController } from './turnController.js'
import { patchUiState } from './uiStore.js'
const STATUSBAR_MODES = new Set<StatusBarMode>(['bottom', 'off', 'on', 'top'])
const STATUSBAR_MODES = new Set<StatusBarMode>(['bottom', 'off', 'top'])
// Legacy configs stored tui_statusbar as a bool; new configs write a string
// ('on' | 'off' | 'bottom' | 'top'). Coerce both shapes so existing users
// keep their preference without manual migration.
// Legacy configs stored tui_statusbar as a bool; the short-lived 4-mode
// variant wrote 'on'. Both map to 'top' (inline above the input) — the
// original feature's default — so users keep their preference without
// manual migration.
export const normalizeStatusBar = (raw: unknown): StatusBarMode => {
if (raw === false) return 'off'
if (raw === true || raw == null) return 'on'
if (raw === true || raw == null || raw === 'on') return 'top'
if (typeof raw === 'string' && STATUSBAR_MODES.has(raw as StatusBarMode)) return raw as StatusBarMode
return 'on'
return 'top'
}
const MTIME_POLL_MS = 5000

View file

@ -184,7 +184,7 @@ const ComposerPane = memo(function ComposerPane({
)}
<Box flexDirection="column" position="relative">
<StatusRulePane at="on" composer={composer} status={status} />
<StatusRulePane at="top" composer={composer} status={status} />
<FloatingOverlays
cols={composer.cols}
@ -261,7 +261,7 @@ const StatusRulePane = memo(function StatusRulePane({
at,
composer,
status
}: Pick<AppLayoutProps, 'composer' | 'status'> & { at: 'bottom' | 'on' | 'top' }) {
}: Pick<AppLayoutProps, 'composer' | 'status'> & { at: 'bottom' | 'top' }) {
const ui = useStore($uiState)
if (ui.statusBar !== at) {
@ -300,8 +300,6 @@ export const AppLayout = memo(function AppLayout({
return (
<AlternateScreen mouseTracking={mouseTracking}>
<Box flexDirection="column" flexGrow={1}>
{!overlay.agents && <StatusRulePane at="top" composer={composer} status={status} />}
<Box flexDirection="row" flexGrow={1}>
{overlay.agents ? (
<AgentsOverlayPane />