refactor(tui): statusbar as 4-mode position (on|off|bottom|top)
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
This commit is contained in:
@@ -455,6 +455,20 @@ def _write_config_key(key_path: str, value):
|
|||||||
_save_cfg(cfg)
|
_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:
|
def _load_reasoning_config() -> dict | None:
|
||||||
from hermes_constants import parse_reasoning_effort
|
from hermes_constants import parse_reasoning_effort
|
||||||
|
|
||||||
@@ -2499,12 +2513,11 @@ def _(rid, params: dict) -> dict:
|
|||||||
)
|
)
|
||||||
return _ok(rid, {"key": key, "value": nv})
|
return _ok(rid, {"key": key, "value": nv})
|
||||||
|
|
||||||
if key in ("compact", "statusbar"):
|
if key == "compact":
|
||||||
raw = str(value or "").strip().lower()
|
raw = str(value or "").strip().lower()
|
||||||
cfg0 = _load_cfg()
|
cfg0 = _load_cfg()
|
||||||
d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {}
|
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("tui_compact", False))
|
||||||
cur_b = bool(d0.get(def_key, False if key == "compact" else True))
|
|
||||||
if raw in ("", "toggle"):
|
if raw in ("", "toggle"):
|
||||||
nv_b = not cur_b
|
nv_b = not cur_b
|
||||||
elif raw == "on":
|
elif raw == "on":
|
||||||
@@ -2512,10 +2525,23 @@ def _(rid, params: dict) -> dict:
|
|||||||
elif raw == "off":
|
elif raw == "off":
|
||||||
nv_b = False
|
nv_b = False
|
||||||
else:
|
else:
|
||||||
return _err(rid, 4002, f"unknown {key} value: {value}")
|
return _err(rid, 4002, f"unknown compact value: {value}")
|
||||||
_write_config_key(f"display.{def_key}", nv_b)
|
_write_config_key("display.tui_compact", nv_b)
|
||||||
out = "on" if nv_b else "off"
|
return _ok(rid, {"key": key, "value": "on" if nv_b else "off"})
|
||||||
return _ok(rid, {"key": key, "value": out})
|
|
||||||
|
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"):
|
if key in ("prompt", "personality", "skin"):
|
||||||
try:
|
try:
|
||||||
@@ -2633,8 +2659,8 @@ def _(rid, params: dict) -> dict:
|
|||||||
on = bool(_load_cfg().get("display", {}).get("tui_compact", False))
|
on = bool(_load_cfg().get("display", {}).get("tui_compact", False))
|
||||||
return _ok(rid, {"value": "on" if on else "off"})
|
return _ok(rid, {"value": "on" if on else "off"})
|
||||||
if key == "statusbar":
|
if key == "statusbar":
|
||||||
on = bool(_load_cfg().get("display", {}).get("tui_statusbar", True))
|
raw = _load_cfg().get("display", {}).get("tui_statusbar", "on")
|
||||||
return _ok(rid, {"value": "on" if on else "off"})
|
return _ok(rid, {"value": _coerce_statusbar(raw)})
|
||||||
if key == "mtime":
|
if key == "mtime":
|
||||||
cfg_path = _hermes_home / "config.yaml"
|
cfg_path = _hermes_home / "config.yaml"
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { $uiState, resetUiState } from '../app/uiStore.js'
|
import { $uiState, resetUiState } from '../app/uiStore.js'
|
||||||
import { applyDisplay } from '../app/useConfigSync.js'
|
import { applyDisplay, normalizeStatusBar } from '../app/useConfigSync.js'
|
||||||
|
|
||||||
describe('applyDisplay', () => {
|
describe('applyDisplay', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -36,7 +36,7 @@ describe('applyDisplay', () => {
|
|||||||
expect(s.inlineDiffs).toBe(false)
|
expect(s.inlineDiffs).toBe(false)
|
||||||
expect(s.showCost).toBe(true)
|
expect(s.showCost).toBe(true)
|
||||||
expect(s.showReasoning).toBe(true)
|
expect(s.showReasoning).toBe(true)
|
||||||
expect(s.statusBar).toBe(false)
|
expect(s.statusBar).toBe('off')
|
||||||
expect(s.streaming).toBe(false)
|
expect(s.streaming).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ describe('applyDisplay', () => {
|
|||||||
expect(s.inlineDiffs).toBe(true)
|
expect(s.inlineDiffs).toBe(true)
|
||||||
expect(s.showCost).toBe(false)
|
expect(s.showCost).toBe(false)
|
||||||
expect(s.showReasoning).toBe(false)
|
expect(s.showReasoning).toBe(false)
|
||||||
expect(s.statusBar).toBe(true)
|
expect(s.statusBar).toBe('on')
|
||||||
expect(s.streaming).toBe(true)
|
expect(s.streaming).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -64,4 +64,35 @@ describe('applyDisplay', () => {
|
|||||||
expect(s.inlineDiffs).toBe(true)
|
expect(s.inlineDiffs).toBe(true)
|
||||||
expect(s.streaming).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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface StateSetter<T> {
|
|||||||
(value: SetStateAction<T>): void
|
(value: SetStateAction<T>): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StatusBarMode = 'bottom' | 'off' | 'on' | 'top'
|
||||||
|
|
||||||
export interface SelectionApi {
|
export interface SelectionApi {
|
||||||
clearSelection: () => void
|
clearSelection: () => void
|
||||||
copySelection: () => string
|
copySelection: () => string
|
||||||
@@ -89,7 +91,7 @@ export interface UiState {
|
|||||||
showReasoning: boolean
|
showReasoning: boolean
|
||||||
sid: null | string
|
sid: null | string
|
||||||
status: string
|
status: string
|
||||||
statusBar: boolean
|
statusBar: StatusBarMode
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
theme: Theme
|
theme: Theme
|
||||||
usage: Usage
|
usage: Usage
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
SessionSteerResponse,
|
SessionSteerResponse,
|
||||||
SessionUndoResponse
|
SessionUndoResponse
|
||||||
} from '../../../gatewayTypes.js'
|
} from '../../../gatewayTypes.js'
|
||||||
|
import type { StatusBarMode } from '../../interfaces.js'
|
||||||
import { writeOsc52Clipboard } from '../../../lib/osc52.js'
|
import { writeOsc52Clipboard } from '../../../lib/osc52.js'
|
||||||
import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js'
|
import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js'
|
||||||
import type { DetailsMode, Msg, PanelSection } from '../../../types.js'
|
import type { DetailsMode, Msg, PanelSection } from '../../../types.js'
|
||||||
@@ -305,19 +306,30 @@ export const coreCommands: SlashCommand[] = [
|
|||||||
|
|
||||||
{
|
{
|
||||||
aliases: ['sb'],
|
aliases: ['sb'],
|
||||||
help: 'toggle status bar',
|
help: 'status bar position (on|off|bottom|top)',
|
||||||
name: 'statusbar',
|
name: 'statusbar',
|
||||||
run: (arg, ctx) => {
|
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) {
|
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 })
|
patchUiState({ statusBar: next })
|
||||||
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {})
|
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'statusbar', value: next }).catch(() => {})
|
||||||
|
|
||||||
queueMicrotask(() => ctx.transcript.sys(`status bar ${next ? 'on' : 'off'}`))
|
queueMicrotask(() => ctx.transcript.sys(`status bar ${next}`))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const buildUiState = (): UiState => ({
|
|||||||
showReasoning: false,
|
showReasoning: false,
|
||||||
sid: null,
|
sid: null,
|
||||||
status: 'summoning hermes…',
|
status: 'summoning hermes…',
|
||||||
statusBar: true,
|
statusBar: 'on',
|
||||||
streaming: true,
|
streaming: true,
|
||||||
theme: DEFAULT_THEME,
|
theme: DEFAULT_THEME,
|
||||||
usage: ZERO
|
usage: ZERO
|
||||||
|
|||||||
@@ -10,9 +10,23 @@ import type {
|
|||||||
} from '../gatewayTypes.js'
|
} from '../gatewayTypes.js'
|
||||||
import { asRpcResult } from '../lib/rpc.js'
|
import { asRpcResult } from '../lib/rpc.js'
|
||||||
|
|
||||||
|
import type { StatusBarMode } from './interfaces.js'
|
||||||
import { turnController } from './turnController.js'
|
import { turnController } from './turnController.js'
|
||||||
import { patchUiState } from './uiStore.js'
|
import { patchUiState } from './uiStore.js'
|
||||||
|
|
||||||
|
const STATUSBAR_MODES = new Set<StatusBarMode>(['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 MTIME_POLL_MS = 5000
|
||||||
|
|
||||||
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
|
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
|
||||||
@@ -37,7 +51,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
|
|||||||
inlineDiffs: d.inline_diffs !== false,
|
inlineDiffs: d.inline_diffs !== false,
|
||||||
showCost: !!d.show_cost,
|
showCost: !!d.show_cost,
|
||||||
showReasoning: !!d.show_reasoning,
|
showReasoning: !!d.show_reasoning,
|
||||||
statusBar: d.tui_statusbar !== false,
|
statusBar: normalizeStatusBar(d.tui_statusbar),
|
||||||
streaming: d.streaming !== false
|
streaming: d.streaming !== false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ const ComposerPane = memo(function ComposerPane({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Box flexDirection="column" position="relative">
|
<Box flexDirection="column" position="relative">
|
||||||
|
<StatusRulePane at="on" composer={composer} status={status} />
|
||||||
|
|
||||||
<FloatingOverlays
|
<FloatingOverlays
|
||||||
cols={composer.cols}
|
cols={composer.cols}
|
||||||
compIdx={composer.compIdx}
|
compIdx={composer.compIdx}
|
||||||
@@ -195,7 +197,7 @@ const ComposerPane = memo(function ComposerPane({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{!isBlocked && (
|
{!isBlocked && (
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={ui.statusBar === 'bottom' ? 0 : 1}>
|
||||||
{composer.inputBuf.map((line, i) => (
|
{composer.inputBuf.map((line, i) => (
|
||||||
<Box key={i}>
|
<Box key={i}>
|
||||||
<Box width={3}>
|
<Box width={3}>
|
||||||
@@ -255,10 +257,14 @@ const AgentsOverlayPane = memo(function AgentsOverlayPane() {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const StatusRulePane = memo(function StatusRulePane({ composer, status }: Pick<AppLayoutProps, 'composer' | 'status'>) {
|
const StatusRulePane = memo(function StatusRulePane({
|
||||||
|
at,
|
||||||
|
composer,
|
||||||
|
status
|
||||||
|
}: Pick<AppLayoutProps, 'composer' | 'status'> & { at: 'bottom' | 'on' | 'top' }) {
|
||||||
const ui = useStore($uiState)
|
const ui = useStore($uiState)
|
||||||
|
|
||||||
if (!ui.statusBar) {
|
if (ui.statusBar !== at) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +300,8 @@ export const AppLayout = memo(function AppLayout({
|
|||||||
return (
|
return (
|
||||||
<AlternateScreen mouseTracking={mouseTracking}>
|
<AlternateScreen mouseTracking={mouseTracking}>
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
{!overlay.agents && <StatusRulePane at="top" composer={composer} status={status} />}
|
||||||
|
|
||||||
<Box flexDirection="row" flexGrow={1}>
|
<Box flexDirection="row" flexGrow={1}>
|
||||||
{overlay.agents ? (
|
{overlay.agents ? (
|
||||||
<AgentsOverlayPane />
|
<AgentsOverlayPane />
|
||||||
@@ -314,7 +322,7 @@ export const AppLayout = memo(function AppLayout({
|
|||||||
|
|
||||||
{!overlay.agents && <ComposerPane actions={actions} composer={composer} status={status} />}
|
{!overlay.agents && <ComposerPane actions={actions} composer={composer} status={status} />}
|
||||||
|
|
||||||
{!overlay.agents && <StatusRulePane composer={composer} status={status} />}
|
{!overlay.agents && <StatusRulePane at="bottom" composer={composer} status={status} />}
|
||||||
</Box>
|
</Box>
|
||||||
</AlternateScreen>
|
</AlternateScreen>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export interface ConfigDisplayConfig {
|
|||||||
streaming?: boolean
|
streaming?: boolean
|
||||||
thinking_mode?: string
|
thinking_mode?: string
|
||||||
tui_compact?: boolean
|
tui_compact?: boolean
|
||||||
tui_statusbar?: boolean
|
tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigFullResponse {
|
export interface ConfigFullResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user