From d55a17bd824ce3ce309eb7cecdad9406cdf5b107 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 13:41:01 -0500 Subject: [PATCH] refactor(tui): statusbar as 4-mode position (on|off|bottom|top) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default is back to 'on' (inline, above the input) — bottom was too far from the input and felt disconnected. Users who want it pinned can opt in explicitly. - UiState.statusBar: boolean → 'on' | 'off' | 'bottom' | 'top' - /statusbar [on|off|bottom|top|toggle]; no-arg still binary-toggles between off and on (preserves muscle memory) - appLayout renders StatusRulePane in three slots (inline inside ComposerPane for 'on', above transcript row for 'top', after ComposerPane for 'bottom'); only the slot matching ui.statusBar actually mounts - drop the input's marginBottom when 'bottom' so the rule sits tight against the input instead of floating a row below - useConfigSync.normalizeStatusBar coerces legacy bool (true→on, false→off) and unknown shapes to 'on' for forward-compat reads - tui_gateway: split compact from statusbar config handlers; persist string enum with _coerce_statusbar helper for legacy bool configs --- tui_gateway/server.py | 44 +++++++++++++++++----- ui-tui/src/__tests__/useConfigSync.test.ts | 37 ++++++++++++++++-- ui-tui/src/app/interfaces.ts | 4 +- ui-tui/src/app/slash/commands/core.ts | 22 ++++++++--- ui-tui/src/app/uiStore.ts | 2 +- ui-tui/src/app/useConfigSync.ts | 16 +++++++- ui-tui/src/components/appLayout.tsx | 16 ++++++-- ui-tui/src/gatewayTypes.ts | 2 +- 8 files changed, 118 insertions(+), 25 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5dd33814d..dfc158076 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -455,6 +455,20 @@ 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. +def _coerce_statusbar(raw) -> str: + if raw is True: + return "on" + if raw is False: + return "off" + if isinstance(raw, str): + s = raw.strip().lower() + if s in {"on", "off", "bottom", "top"}: + return s + return "on" + + def _load_reasoning_config() -> dict | None: from hermes_constants import parse_reasoning_effort @@ -2499,12 +2513,11 @@ def _(rid, params: dict) -> dict: ) return _ok(rid, {"key": key, "value": nv}) - if key in ("compact", "statusbar"): + if key == "compact": raw = str(value or "").strip().lower() cfg0 = _load_cfg() d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {} - def_key = "tui_compact" if key == "compact" else "tui_statusbar" - cur_b = bool(d0.get(def_key, False if key == "compact" else True)) + cur_b = bool(d0.get("tui_compact", False)) if raw in ("", "toggle"): nv_b = not cur_b elif raw == "on": @@ -2512,10 +2525,23 @@ def _(rid, params: dict) -> dict: elif raw == "off": nv_b = False else: - return _err(rid, 4002, f"unknown {key} value: {value}") - _write_config_key(f"display.{def_key}", nv_b) - out = "on" if nv_b else "off" - return _ok(rid, {"key": key, "value": out}) + return _err(rid, 4002, f"unknown compact value: {value}") + _write_config_key("display.tui_compact", nv_b) + return _ok(rid, {"key": key, "value": "on" if nv_b else "off"}) + + if key == "statusbar": + 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")) + if raw in ("", "toggle"): + nv = "on" if current == "off" else "off" + elif raw in ("on", "off", "bottom", "top"): + nv = raw + else: + return _err(rid, 4002, f"unknown statusbar value: {value}") + _write_config_key("display.tui_statusbar", nv) + return _ok(rid, {"key": key, "value": nv}) if key in ("prompt", "personality", "skin"): try: @@ -2633,8 +2659,8 @@ 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": - on = bool(_load_cfg().get("display", {}).get("tui_statusbar", True)) - return _ok(rid, {"value": "on" if on else "off"}) + raw = _load_cfg().get("display", {}).get("tui_statusbar", "on") + return _ok(rid, {"value": _coerce_statusbar(raw)}) if key == "mtime": cfg_path = _hermes_home / "config.yaml" try: diff --git a/ui-tui/src/__tests__/useConfigSync.test.ts b/ui-tui/src/__tests__/useConfigSync.test.ts index c14ecff3a..09b21b14c 100644 --- a/ui-tui/src/__tests__/useConfigSync.test.ts +++ b/ui-tui/src/__tests__/useConfigSync.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { $uiState, resetUiState } from '../app/uiStore.js' -import { applyDisplay } from '../app/useConfigSync.js' +import { applyDisplay, normalizeStatusBar } from '../app/useConfigSync.js' describe('applyDisplay', () => { beforeEach(() => { @@ -36,7 +36,7 @@ describe('applyDisplay', () => { expect(s.inlineDiffs).toBe(false) expect(s.showCost).toBe(true) expect(s.showReasoning).toBe(true) - expect(s.statusBar).toBe(false) + expect(s.statusBar).toBe('off') expect(s.streaming).toBe(false) }) @@ -50,7 +50,7 @@ describe('applyDisplay', () => { expect(s.inlineDiffs).toBe(true) expect(s.showCost).toBe(false) expect(s.showReasoning).toBe(false) - expect(s.statusBar).toBe(true) + expect(s.statusBar).toBe('on') expect(s.streaming).toBe(true) }) @@ -64,4 +64,35 @@ describe('applyDisplay', () => { expect(s.inlineDiffs).toBe(true) expect(s.streaming).toBe(true) }) + + it('accepts the new string statusBar modes', () => { + const setBell = vi.fn() + + applyDisplay({ config: { display: { tui_statusbar: 'bottom' } } }, setBell) + expect($uiState.get().statusBar).toBe('bottom') + + applyDisplay({ config: { display: { tui_statusbar: 'top' } } }, setBell) + expect($uiState.get().statusBar).toBe('top') + }) +}) + +describe('normalizeStatusBar', () => { + it('maps legacy bool to on/off', () => { + expect(normalizeStatusBar(true)).toBe('on') + expect(normalizeStatusBar(false)).toBe('off') + }) + + it('passes through the new string enum', () => { + expect(normalizeStatusBar('on')).toBe('on') + expect(normalizeStatusBar('off')).toBe('off') + expect(normalizeStatusBar('bottom')).toBe('bottom') + expect(normalizeStatusBar('top')).toBe('top') + }) + + 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') + }) }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index f14c232f0..e44cdc060 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -27,6 +27,8 @@ export interface StateSetter { (value: SetStateAction): void } +export type StatusBarMode = 'bottom' | 'off' | 'on' | 'top' + export interface SelectionApi { clearSelection: () => void copySelection: () => string @@ -89,7 +91,7 @@ export interface UiState { showReasoning: boolean sid: null | string status: string - statusBar: boolean + statusBar: StatusBarMode streaming: boolean theme: Theme usage: Usage diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 77eb20dec..13672c0d1 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -8,6 +8,7 @@ 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' @@ -305,19 +306,30 @@ export const coreCommands: SlashCommand[] = [ { aliases: ['sb'], - help: 'toggle status bar', + help: 'status bar position (on|off|bottom|top)', name: 'statusbar', run: (arg, ctx) => { - const next = flagFromArg(arg, ctx.ui.statusBar) + 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. + const next: null | StatusBarMode = + mode === '' || mode === 'toggle' + ? current === 'off' + ? 'on' + : 'off' + : mode === 'on' || mode === 'off' || mode === 'bottom' || mode === 'top' + ? mode + : null if (next === null) { - return ctx.transcript.sys('usage: /statusbar [on|off|toggle]') + return ctx.transcript.sys('usage: /statusbar [on|off|bottom|top|toggle]') } patchUiState({ statusBar: next }) - ctx.gateway.rpc('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {}) + ctx.gateway.rpc('config.set', { key: 'statusbar', value: next }).catch(() => {}) - queueMicrotask(() => ctx.transcript.sys(`status bar ${next ? 'on' : 'off'}`)) + queueMicrotask(() => ctx.transcript.sys(`status bar ${next}`)) } }, diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 81089f179..761c2d074 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -16,7 +16,7 @@ const buildUiState = (): UiState => ({ showReasoning: false, sid: null, status: 'summoning hermes…', - statusBar: true, + statusBar: 'on', streaming: true, theme: DEFAULT_THEME, usage: ZERO diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 8a3756342..ac363e4dd 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -10,9 +10,23 @@ import type { } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' +import type { StatusBarMode } from './interfaces.js' import { turnController } from './turnController.js' import { patchUiState } from './uiStore.js' +const STATUSBAR_MODES = new Set(['bottom', 'off', 'on', '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. +export const normalizeStatusBar = (raw: unknown): StatusBarMode => { + if (raw === false) return 'off' + if (raw === true || raw == null) return 'on' + if (typeof raw === 'string' && STATUSBAR_MODES.has(raw as StatusBarMode)) return raw as StatusBarMode + + return 'on' +} + const MTIME_POLL_MS = 5000 const quietRpc = async = Record>( @@ -37,7 +51,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea inlineDiffs: d.inline_diffs !== false, showCost: !!d.show_cost, showReasoning: !!d.show_reasoning, - statusBar: d.tui_statusbar !== false, + statusBar: normalizeStatusBar(d.tui_statusbar), streaming: d.streaming !== false }) } diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index d04922b7d..517a65c6e 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -184,6 +184,8 @@ const ComposerPane = memo(function ComposerPane({ )} + + {!isBlocked && ( - + {composer.inputBuf.map((line, i) => ( @@ -255,10 +257,14 @@ const AgentsOverlayPane = memo(function AgentsOverlayPane() { ) }) -const StatusRulePane = memo(function StatusRulePane({ composer, status }: Pick) { +const StatusRulePane = memo(function StatusRulePane({ + at, + composer, + status +}: Pick & { at: 'bottom' | 'on' | 'top' }) { const ui = useStore($uiState) - if (!ui.statusBar) { + if (ui.statusBar !== at) { return null } @@ -294,6 +300,8 @@ export const AppLayout = memo(function AppLayout({ return ( + {!overlay.agents && } + {overlay.agents ? ( @@ -314,7 +322,7 @@ export const AppLayout = memo(function AppLayout({ {!overlay.agents && } - {!overlay.agents && } + {!overlay.agents && } ) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 975ec117e..1dc8ea5be 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -60,7 +60,7 @@ export interface ConfigDisplayConfig { streaming?: boolean thinking_mode?: string tui_compact?: boolean - tui_statusbar?: boolean + tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean } export interface ConfigFullResponse {