From 7027ce42efd2849c62146daf813ab04911ee14bd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 13:28:44 -0500 Subject: [PATCH 01/16] =?UTF-8?q?fix(tui):=20blitz=20closeout=20=E2=80=94?= =?UTF-8?q?=20input=20wrap=20parity,=20shift-tab=20yolo,=20bottom=20status?= =?UTF-8?q?line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - input wrap: add mode that drives wrap-ansi with wordWrap:false, and align cursorLayout/offsetFromPosition to that same boundary (w=cols, trailing-cell overflow). Word-wrap's whitespace reshuffle was causing the cursor to jump a word left/right on each keystroke near the right edge — blitz row 9 - shift-tab: toggle per-session yolo without submitting a turn (mirrors Claude Code's in-place dangerously-approve); slash /yolo still works for discoverability — blitz row 5 sub-item 11 - statusline: lift StatusRule out of ComposerPane to a new StatusRulePane anchored at the bottom of AppLayout, below the input — blitz row 5 sub-item 12 --- .../hermes-ink/src/ink/components/Text.tsx | 6 ++ .../src/ink/render-node-to-output.ts | 2 +- ui-tui/packages/hermes-ink/src/ink/styles.ts | 1 + .../packages/hermes-ink/src/ink/wrap-text.ts | 13 ++++ ui-tui/src/__tests__/textInputWrap.test.ts | 60 +++++++++++++++++++ ui-tui/src/app/useInputHandlers.ts | 11 ++++ ui-tui/src/components/appLayout.tsx | 46 ++++++++------ ui-tui/src/components/textInput.tsx | 25 ++++++-- 8 files changed, 140 insertions(+), 24 deletions(-) create mode 100644 ui-tui/src/__tests__/textInputWrap.test.ts diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx index ea2a74c9a..9459b78a2 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx @@ -69,6 +69,12 @@ const memoizedStylesForWrap: Record, Styles> = { flexDirection: 'row', textWrap: 'wrap' }, + 'wrap-char': { + flexGrow: 0, + flexShrink: 1, + flexDirection: 'row', + textWrap: 'wrap-char' + }, 'wrap-trim': { flexGrow: 0, flexShrink: 1, diff --git a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts index 5c9e62b46..dd7372a09 100644 --- a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts @@ -343,7 +343,7 @@ function wrapWithSoftWrap( maxWidth: number, textWrap: Parameters[2] ): { wrapped: string; softWrap: boolean[] | undefined } { - if (textWrap !== 'wrap' && textWrap !== 'wrap-trim') { + if (textWrap !== 'wrap' && textWrap !== 'wrap-char' && textWrap !== 'wrap-trim') { return { wrapped: wrapText(plainText, maxWidth, textWrap), softWrap: undefined diff --git a/ui-tui/packages/hermes-ink/src/ink/styles.ts b/ui-tui/packages/hermes-ink/src/ink/styles.ts index e5321f6e5..0fa6cc66e 100644 --- a/ui-tui/packages/hermes-ink/src/ink/styles.ts +++ b/ui-tui/packages/hermes-ink/src/ink/styles.ts @@ -55,6 +55,7 @@ export type TextStyles = { export type Styles = { readonly textWrap?: | 'wrap' + | 'wrap-char' | 'wrap-trim' | 'end' | 'middle' diff --git a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts index 4d157bc2a..c0b95df08 100644 --- a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts +++ b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts @@ -50,6 +50,19 @@ export default function wrapText(text: string, maxWidth: number, wrapType: Style }) } + // Char-granularity wrap: break at exact column boundaries regardless of + // whitespace. Used for text inputs where the cursor position must track + // the wrap boundary deterministically — word-wrap's whitespace-preferring + // reshuffle causes visible cursor flicker as each keystroke can push a + // word across a line break. + if (wrapType === 'wrap-char') { + return wrapAnsi(text, maxWidth, { + trim: false, + hard: true, + wordWrap: false + }) + } + if (wrapType === 'wrap-trim') { return wrapAnsi(text, maxWidth, { trim: true, diff --git a/ui-tui/src/__tests__/textInputWrap.test.ts b/ui-tui/src/__tests__/textInputWrap.test.ts new file mode 100644 index 000000000..9414b9fbd --- /dev/null +++ b/ui-tui/src/__tests__/textInputWrap.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' + +import { cursorLayout, offsetFromPosition } from '../components/textInput.js' + +describe('cursorLayout — char-wrap parity with wrap-ansi', () => { + it('places cursor mid-line at its column', () => { + expect(cursorLayout('hello world', 6, 40)).toEqual({ column: 6, line: 0 }) + }) + + it('places cursor at end of a non-full line', () => { + expect(cursorLayout('hi', 2, 10)).toEqual({ column: 2, line: 0 }) + }) + + it('wraps to next line when cursor lands exactly at the right edge', () => { + // 8 chars on an 8-col line: text fills the row exactly; the cursor's + // inverted-space cell overflows to col 0 of the next row. + expect(cursorLayout('abcdefgh', 8, 8)).toEqual({ column: 0, line: 1 }) + }) + + it('tracks a word across a char-wrap boundary without jumping', () => { + // With wordWrap:false, "hello world" at cols=8 is "hello wo\nrld" — + // typing incremental letters doesn't reshuffle the word across lines. + expect(cursorLayout('hello wo', 8, 8)).toEqual({ column: 0, line: 1 }) + expect(cursorLayout('hello wor', 9, 8)).toEqual({ column: 1, line: 1 }) + expect(cursorLayout('hello worl', 10, 8)).toEqual({ column: 2, line: 1 }) + }) + + it('honours explicit newlines', () => { + expect(cursorLayout('one\ntwo', 5, 40)).toEqual({ column: 1, line: 1 }) + expect(cursorLayout('one\ntwo', 4, 40)).toEqual({ column: 0, line: 1 }) + }) + + it('does not wrap when cursor is before the right edge', () => { + expect(cursorLayout('abcdefg', 7, 8)).toEqual({ column: 7, line: 0 }) + }) +}) + +describe('offsetFromPosition — char-wrap inverse of cursorLayout', () => { + it('returns 0 for empty input', () => { + expect(offsetFromPosition('', 0, 0, 10)).toBe(0) + }) + + it('maps clicks within a single line', () => { + expect(offsetFromPosition('hello', 0, 3, 40)).toBe(3) + }) + + it('maps clicks past end to value length', () => { + expect(offsetFromPosition('hi', 0, 10, 40)).toBe(2) + }) + + it('maps clicks on a wrapped second row at cols boundary', () => { + // "abcdefghij" at cols=8 wraps to "abcdefgh\nij" — click at row 1 col 0 + // should land on 'i' (offset 8). + expect(offsetFromPosition('abcdefghij', 1, 0, 8)).toBe(8) + }) + + it('maps clicks past a \\n into the target line', () => { + expect(offsetFromPosition('one\ntwo', 1, 2, 40)).toBe(6) + }) +}) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 9d3ccdf09..bb88383ae 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react' import type { ApprovalRespondResponse, + ConfigSetResponse, SecretRespondResponse, SudoRespondResponse, VoiceRecordResponse @@ -377,6 +378,16 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return cActions.openEditor() } + // Shift-Tab toggles per-session yolo without submitting a turn — mirrors + // Claude Code's in-place dangerously-approve toggle. Slash /yolo keeps + // working for discoverability; this just skips the inference round-trip + // when you only want to flip the flag mid-flow (blitz #5 sub-item 11). + if (key.shift && key.tab && !cState.completions.length) { + return void gateway + .rpc('config.set', { key: 'yolo', session_id: live.sid }) + .then(r => actions.sys(`yolo ${r?.value === '1' ? 'on' : 'off'}`)) + } + if (key.tab && cState.completions.length) { const row = cState.completions[cState.compIdx] diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 959b6ea70..d04922b7d 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -184,24 +184,6 @@ const ComposerPane = memo(function ComposerPane({ )} - {ui.statusBar && ( - - )} - ) { + const ui = useStore($uiState) + + if (!ui.statusBar) { + return null + } + + return ( + + ) +}) + export const AppLayout = memo(function AppLayout({ actions, composer, @@ -305,6 +313,8 @@ export const AppLayout = memo(function AppLayout({ )} {!overlay.agents && } + + {!overlay.agents && } ) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 12b228c1f..4b6950cf5 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -167,9 +167,14 @@ export function lineNav(s: string, p: number, dir: -1 | 1): null | number { return snapPos(s, Math.min(nextBreak + 1 + col, lineEnd)) } -function cursorLayout(value: string, cursor: number, cols: number) { +// Cursor layout mirrors `wrap-ansi(text, cols, { wordWrap: false, hard: true })` +// which is what `` ends up feeding to the renderer. +// Char-granularity wrap keeps wrap boundaries deterministic as the user +// types — word-wrap's whitespace-preferring reshuffle causes the cursor +// to flicker as each keystroke moves a word across a line break (blitz #9). +export function cursorLayout(value: string, cursor: number, cols: number) { const pos = Math.max(0, Math.min(cursor, value.length)) - const w = Math.max(1, cols - 1) + const w = Math.max(1, cols) let col = 0, line = 0 @@ -200,17 +205,27 @@ function cursorLayout(value: string, cursor: number, cols: number) { col += sw } + // The cursor renders as an inverted cell AFTER the character at `pos` + // (or as a standalone trailing cell when `pos === value.length`). If + // col has reached the wrap column, that cell overflows to the next row + // — match wrap-ansi's behavior so the declared cursor doesn't sit past + // the visual edge. + if (col >= w) { + line++ + col = 0 + } + return { column: col, line } } -function offsetFromPosition(value: string, row: number, col: number, cols: number) { +export function offsetFromPosition(value: string, row: number, col: number, cols: number) { if (!value.length) { return 0 } const targetRow = Math.max(0, Math.floor(row)) const targetCol = Math.max(0, Math.floor(col)) - const w = Math.max(1, cols - 1) + const w = Math.max(1, cols) let line = 0 let column = 0 @@ -800,7 +815,7 @@ export function TextInput({ }} ref={boxRef} > - {rendered} + {rendered} ) } From d55a17bd824ce3ce309eb7cecdad9406cdf5b107 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 13:41:01 -0500 Subject: [PATCH 02/16] 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 { From ea32364c965534642077653f3a82720ce48d90c6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 13:55:40 -0500 Subject: [PATCH 03/16] fix(tui): /statusbar top = inline above input, not row 0 of the screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '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. --- tui_gateway/server.py | 24 ++++++++++------ ui-tui/src/__tests__/useConfigSync.test.ts | 32 ++++++++++++++-------- ui-tui/src/app/interfaces.ts | 2 +- ui-tui/src/app/slash/commands/core.ts | 22 +++++++++------ ui-tui/src/app/uiStore.ts | 2 +- ui-tui/src/app/useConfigSync.ts | 13 +++++---- ui-tui/src/components/appLayout.tsx | 6 ++-- 7 files changed, 60 insertions(+), 41 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index dfc158076..32bac4ffa 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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" diff --git a/ui-tui/src/__tests__/useConfigSync.test.ts b/ui-tui/src/__tests__/useConfigSync.test.ts index 09b21b14c..b5b25ddd8 100644 --- a/ui-tui/src/__tests__/useConfigSync.test.ts +++ b/ui-tui/src/__tests__/useConfigSync.test.ts @@ -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') }) }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index e44cdc060..c1c427739 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -27,7 +27,7 @@ export interface StateSetter { (value: SetStateAction): void } -export type StatusBarMode = 'bottom' | 'off' | 'on' | 'top' +export type StatusBarMode = 'bottom' | 'off' | 'top' export interface SelectionApi { clearSelection: () => void diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 13672c0d1..a418e28ac 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -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 }) diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 761c2d074..fcf2e5d88 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: 'on', + statusBar: 'top', streaming: true, theme: DEFAULT_THEME, usage: ZERO diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index ac363e4dd..a8f64c3a5 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -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(['bottom', 'off', 'on', 'top']) +const STATUSBAR_MODES = new Set(['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 diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 517a65c6e..d2607bad2 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -184,7 +184,7 @@ const ComposerPane = memo(function ComposerPane({ )} - + & { at: 'bottom' | 'on' | 'top' }) { +}: Pick & { at: 'bottom' | 'top' }) { const ui = useStore($uiState) if (ui.statusBar !== at) { @@ -300,8 +300,6 @@ export const AppLayout = memo(function AppLayout({ return ( - {!overlay.agents && } - {overlay.agents ? ( From 408fc893e93c29e90069f5c455a6abd5c5c9594e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 14:19:01 -0500 Subject: [PATCH 04/16] =?UTF-8?q?fix(tui):=20tighten=20composer=20?= =?UTF-8?q?=E2=80=94=20status=20sits=20directly=20above=20input,=20overlay?= =?UTF-8?q?s=20anchor=20to=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs rolled together, all in the composer area: - StatusRule was measuring as 2 rows in Yoga due to a quirk with the complex nested content. Lock the outer box to height={1} so 'top' mode actually abuts the input instead of leaving a phantom blank row between them - FloatingOverlays (slash completions, /model picker, /resume, /skills browser, pager) was anchored to the status box. In 'bottom' mode the status box moved away, so overlays vanished. Move the overlays into the input row (which is position:relative) so they always pop up above the input regardless of status position - Drop the fallback in the sticky-prompt slot (only render a row when there's an actual sticky prompt to show) and collapse the now-unused Box column wrapping the input. Saves two rows of dead vertical space in the default layout --- ui-tui/src/components/appChrome.tsx | 2 +- ui-tui/src/components/appLayout.tsx | 30 +++++++++++++---------------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 2fe2e6a5b..3d14f5003 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -187,7 +187,7 @@ export function StatusRule({ const leftWidth = Math.max(12, cols - cwdLabel.length - 3) return ( - + {'─ '} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index d2607bad2..6e4119cfe 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -173,31 +173,18 @@ const ComposerPane = memo(function ComposerPane({ )} - {status.showStickyPrompt ? ( + {status.showStickyPrompt && ( {status.stickyPrompt} - ) : ( - )} - - - - - + {!isBlocked && ( - + <> {composer.inputBuf.map((line, i) => ( @@ -209,6 +196,15 @@ const ComposerPane = memo(function ComposerPane({ ))} + + {sh ? ( $ @@ -234,7 +230,7 @@ const ComposerPane = memo(function ComposerPane({ - + )} {!composer.empty && !ui.sid && ⚕ {ui.status}} From a7cc903bf58bef1de6e70f9aa19ec5c4c89567fa Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 14:28:47 -0500 Subject: [PATCH 05/16] fix(tui): breathing room above the composer cluster, status tight to input Previous revision added marginTop={1} to the input which stacked as a phantom gap BETWEEN status and input. The breathing row should sit ABOVE the status-in-top cluster, not inside it. - StatusRulePane at="top" now carries its own marginTop={1} so it always has a one-row gap above (separating it from transcript or, when queue is present, from the last queue item) - Input Box marginTop flips: 0 in top mode (status is the separator), 1 in bottom/off mode (input itself caps the composer cluster) - Net: status and input are tight together in 'top'; input and status are tight together at the bottom in 'bottom'; one-row breathing room above whichever element sits on top of the cluster --- ui-tui/src/components/appChrome.tsx | 4 +- ui-tui/src/components/appLayout.tsx | 58 ++++++++++++++++------------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 3d14f5003..bffcd6c51 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -1,6 +1,6 @@ -import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' +import { Box, Text, type ScrollBoxHandle } from '@hermes/ink' import { useStore } from '@nanostores/react' -import { type ReactNode, type RefObject, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type ReactNode, type RefObject } from 'react' import { $delegationState } from '../app/delegationStore.js' import { $turnState } from '../app/turnStore.js' diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 6e4119cfe..6cb6de59e 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -184,7 +184,16 @@ const ComposerPane = memo(function ComposerPane({ {!isBlocked && ( - <> + + + {composer.inputBuf.map((line, i) => ( @@ -196,15 +205,6 @@ const ComposerPane = memo(function ComposerPane({ ))} - - {sh ? ( $ @@ -230,7 +230,7 @@ const ComposerPane = memo(function ComposerPane({ - + )} {!composer.empty && !ui.sid && ⚕ {ui.status}} @@ -264,22 +264,28 @@ const StatusRulePane = memo(function StatusRulePane({ return null } + // 'top' sits inline above the input; give it one row of breathing + // space above so the transcript (or queue) doesn't butt directly + // against the status row. 'bottom' lives at the last row of the + // viewport so it needs no margin. return ( - + + + ) }) From 88993a468f307614a8eff721c5635061896d7084 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 14:40:54 -0500 Subject: [PATCH 06/16] =?UTF-8?q?fix(tui):=20input=20wrap=20width=20mismat?= =?UTF-8?q?ch=20=E2=80=94=20last=20letter=20no=20longer=20flickers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'columns' prop passed to TextInput was cols - pw, but the actual render width is cols - pw - 2 (NoSelect's paddingX={1} on each side subtracts two cols from the composer area). cursorLayout thought it had two extra cols, so wrap-ansi wrapped at render col N while the declared cursor sat at col N+2 on the same row. The render and the declared cursor disagreed right at the wrap boundary — the last letter of a sentence spanning two lines flickered in/out as each keystroke flipped which cell the cursor claimed. Also polish the /help hotkeys panel — the !cmd / {!cmd} placeholders read as literal commands to type, so show them with angle-bracket syntax and a concrete example (blitz row 5 sub-item 4). --- ui-tui/src/components/appLayout.tsx | 9 ++++++++- ui-tui/src/content/hotkeys.ts | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 6cb6de59e..171ee27f5 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -216,8 +216,15 @@ const ComposerPane = memo(function ComposerPane({ + {/* + Subtract the NoSelect paddingX={1} (2 cols total) and the + prompt-glyph column (pw) so cursorLayout agrees with the + width wrap-ansi actually uses at render time. Off-by-one/ + two here manifests as the final letter flickering + in/out when a sentence crosses the wrap boundary. + */} ', 'run a shell command (e.g. !ls, !git status)'], + ['{!}', 'interpolate shell output inline (e.g. "branch is {!git branch --show-current}")'] ] From 1e8cfa909219a19ba491be2e388166e7ae904fdd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 14:43:25 -0500 Subject: [PATCH 07/16] fix(tui): idle good-vibes heart no longer blanks the input's last cell The heart was rendered as a literal space when inactive. Because it's absolutely positioned at right:0 inside the composer row, that blank still overpainted the rightmost input cell. On wrapped 2-line drafts, editing near the boundary made the final visible character appear to jump in/out as it crossed the overpainted column. When inactive, render nothing; only mount the heart while it's actually animating. --- ui-tui/src/components/appChrome.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index bffcd6c51..439afd962 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -156,7 +156,11 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { return () => clearTimeout(id) }, [t.color.amber, tick]) - return {active ? '♥' : ' '} + if (!active) { + return null + } + + return } export function StatusRule({ From 48f2ac33528ee68561ecc685a649c15cb0adc148 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 14:54:42 -0500 Subject: [PATCH 08/16] =?UTF-8?q?refactor(tui):=20/clean=20pass=20on=20bli?= =?UTF-8?q?tz=20closeout=20=E2=80=94=20trim=20comments,=20flatten=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - normalizeStatusBar collapses to one ternary expression - /statusbar slash hoists the toggle value and flattens the branch tree - shift-tab yolo comment reduced to one line - cursorLayout/offsetFromPosition lose paragraph-length comments - appLayout collapses the three {!overlay.agents && …} into one fragment - StatusRule drops redundant flexShrink={0} (Yoga default) - server.py uses a walrus + frozenset and trims the compat helper Net -43 LoC. 237 vitest + 46 pytest green, layouts unchanged. --- tui_gateway/server.py | 24 +++++------- .../packages/hermes-ink/src/ink/wrap-text.ts | 11 +----- ui-tui/src/app/slash/commands/core.ts | 13 ++----- ui-tui/src/app/useConfigSync.ts | 17 +++------ ui-tui/src/app/useInputHandlers.ts | 5 +-- ui-tui/src/components/appChrome.tsx | 2 +- ui-tui/src/components/appLayout.tsx | 38 ++++++++----------- ui-tui/src/components/textInput.tsx | 13 ++----- 8 files changed, 40 insertions(+), 83 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 32bac4ffa..49cd9660b 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -455,21 +455,14 @@ def _write_config_key(key_path: str, value): _save_cfg(cfg) -# 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. +_STATUSBAR_MODES = frozenset({"off", "top", "bottom"}) + + def _coerce_statusbar(raw) -> str: - if raw is True: - return "top" if raw is False: return "off" - if isinstance(raw, str): - s = raw.strip().lower() - if s == "on": - return "top" - if s in {"off", "top", "bottom"}: - return s + if isinstance(raw, str) and (s := raw.strip().lower()) in _STATUSBAR_MODES: + return s return "top" @@ -2535,17 +2528,18 @@ def _(rid, params: dict) -> dict: if key == "statusbar": raw = str(value or "").strip().lower() - cfg0 = _load_cfg() - d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {} + d0 = _load_cfg().get("display") or {} current = _coerce_statusbar(d0.get("tui_statusbar", "top")) + if raw in ("", "toggle"): nv = "top" if current == "off" else "off" elif raw == "on": nv = "top" - elif raw in ("off", "top", "bottom"): + elif raw in _STATUSBAR_MODES: 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}) diff --git a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts index c0b95df08..e8290feac 100644 --- a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts +++ b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts @@ -50,17 +50,8 @@ export default function wrapText(text: string, maxWidth: number, wrapType: Style }) } - // Char-granularity wrap: break at exact column boundaries regardless of - // whitespace. Used for text inputs where the cursor position must track - // the wrap boundary deterministically — word-wrap's whitespace-preferring - // reshuffle causes visible cursor flicker as each keystroke can push a - // word across a line break. if (wrapType === 'wrap-char') { - return wrapAnsi(text, maxWidth, { - trim: false, - hard: true, - wordWrap: false - }) + return wrapAnsi(text, maxWidth, { trim: false, hard: true, wordWrap: false }) } if (wrapType === 'wrap-trim') { diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index a418e28ac..904882c21 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -310,23 +310,18 @@ export const coreCommands: SlashCommand[] = [ name: 'statusbar', run: (arg, ctx) => { const mode = arg.trim().toLowerCase() - const current = ctx.ui.statusBar + const toggle: StatusBarMode = ctx.ui.statusBar === 'off' ? 'top' : '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' - ? 'top' - : 'off' + !mode || mode === 'toggle' + ? toggle : mode === 'on' || mode === 'top' ? 'top' : mode === 'off' || mode === 'bottom' ? mode : null - if (next === null) { + if (!next) { return ctx.transcript.sys('usage: /statusbar [on|off|top|bottom|toggle]') } diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index a8f64c3a5..f50dcbd10 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -16,17 +16,12 @@ import { patchUiState } from './uiStore.js' const STATUSBAR_MODES = new Set(['bottom', 'off', 'top']) -// 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 || raw === 'on') return 'top' - if (typeof raw === 'string' && STATUSBAR_MODES.has(raw as StatusBarMode)) return raw as StatusBarMode - - return 'top' -} +export const normalizeStatusBar = (raw: unknown): StatusBarMode => + raw === false + ? 'off' + : typeof raw === 'string' && STATUSBAR_MODES.has(raw as StatusBarMode) + ? (raw as StatusBarMode) + : 'top' const MTIME_POLL_MS = 5000 diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index bb88383ae..07f241cb5 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -378,10 +378,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return cActions.openEditor() } - // Shift-Tab toggles per-session yolo without submitting a turn — mirrors - // Claude Code's in-place dangerously-approve toggle. Slash /yolo keeps - // working for discoverability; this just skips the inference round-trip - // when you only want to flip the flag mid-flow (blitz #5 sub-item 11). + // shift-tab flips yolo without spending a turn (claude-code parity) if (key.shift && key.tab && !cState.completions.length) { return void gateway .rpc('config.set', { key: 'yolo', session_id: live.sid }) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 439afd962..b9b5e9450 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -191,7 +191,7 @@ export function StatusRule({ const leftWidth = Math.max(12, cols - cwdLabel.length - 3) return ( - + {'─ '} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 171ee27f5..c96960ac8 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -216,13 +216,7 @@ const ComposerPane = memo(function ComposerPane({ - {/* - Subtract the NoSelect paddingX={1} (2 cols total) and the - prompt-glyph column (pw) so cursorLayout agrees with the - width wrap-ansi actually uses at render time. Off-by-one/ - two here manifests as the final letter flickering - in/out when a sentence crosses the wrap boundary. - */} + {/* subtract NoSelect paddingX={1} (2 cols) + pw so wrap-ansi and cursorLayout agree */} + {!overlay.agents && ( - + <> + + + + + + )} - - {!overlay.agents && } - - {!overlay.agents && } ) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 4b6950cf5..d17631cfe 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -167,11 +167,8 @@ export function lineNav(s: string, p: number, dir: -1 | 1): null | number { return snapPos(s, Math.min(nextBreak + 1 + col, lineEnd)) } -// Cursor layout mirrors `wrap-ansi(text, cols, { wordWrap: false, hard: true })` -// which is what `` ends up feeding to the renderer. -// Char-granularity wrap keeps wrap boundaries deterministic as the user -// types — word-wrap's whitespace-preferring reshuffle causes the cursor -// to flicker as each keystroke moves a word across a line break (blitz #9). +// mirrors wrap-ansi(..., { wordWrap: false, hard: true }) so the declared +// cursor lines up with what actually renders export function cursorLayout(value: string, cursor: number, cols: number) { const pos = Math.max(0, Math.min(cursor, value.length)) const w = Math.max(1, cols) @@ -205,11 +202,7 @@ export function cursorLayout(value: string, cursor: number, cols: number) { col += sw } - // The cursor renders as an inverted cell AFTER the character at `pos` - // (or as a standalone trailing cell when `pos === value.length`). If - // col has reached the wrap column, that cell overflows to the next row - // — match wrap-ansi's behavior so the declared cursor doesn't sit past - // the visual edge. + // trailing cursor-cell overflows to the next row at the wrap column if (col >= w) { line++ col = 0 From 6fb98f343a7b315ddba4589a8942ba5b5a9c5ab1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 15:19:50 -0500 Subject: [PATCH 09/16] fix(tui): address copilot review on #14103 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - normalizeStatusBar: trim/lowercase + 'on' → 'top' alias so user-edited YAML variants (Top, " bottom ", on) coerce correctly - shift-tab yolo: no-op with sys note when no live session; success-gated echo and catch fallback so RPC failures don't report as 'yolo off' - tui_gateway config.set/get statusbar: isinstance(display, dict) guards mirroring the compact branch so a malformed display scalar in config.yaml can't raise Tests: +1 vitest for trim/case/on, +2 pytest for non-dict display survival. --- tests/test_tui_gateway_server.py | 469 ++++++++++++++++----- tui_gateway/server.py | 8 +- ui-tui/src/__tests__/useConfigSync.test.ts | 7 + ui-tui/src/app/useConfigSync.ts | 20 +- ui-tui/src/app/useInputHandlers.ts | 7 +- 5 files changed, 399 insertions(+), 112 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 7a7f63284..ab7b52df0 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -106,11 +106,23 @@ def test_config_set_yolo_toggles_session_scope(): server._sessions["sid"] = _session() try: - resp_on = server.handle_request({"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "yolo"}}) + resp_on = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "yolo"}, + } + ) assert resp_on["result"]["value"] == "1" assert is_session_yolo_enabled("session-key") is True - resp_off = server.handle_request({"id": "2", "method": "config.set", "params": {"session_id": "sid", "key": "yolo"}}) + resp_off = server.handle_request( + { + "id": "2", + "method": "config.set", + "params": {"session_id": "sid", "key": "yolo"}, + } + ) assert resp_off["result"]["value"] == "0" assert is_session_yolo_enabled("session-key") is False finally: @@ -118,6 +130,36 @@ def test_config_set_yolo_toggles_session_scope(): server._sessions.clear() +def test_config_get_statusbar_survives_non_dict_display(monkeypatch): + monkeypatch.setattr(server, "_load_cfg", lambda: {"display": "broken"}) + + resp = server.handle_request( + {"id": "1", "method": "config.get", "params": {"key": "statusbar"}} + ) + + assert resp["result"]["value"] == "top" + + +def test_config_set_statusbar_survives_non_dict_display(tmp_path, monkeypatch): + import yaml + + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({"display": "broken"})) + monkeypatch.setattr(server, "_hermes_home", tmp_path) + + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"key": "statusbar", "value": "bottom"}, + } + ) + + assert resp["result"]["value"] == "bottom" + saved = yaml.safe_load(cfg_path.read_text()) + assert saved["display"]["tui_statusbar"] == "bottom" + + def test_enable_gateway_prompts_sets_gateway_env(monkeypatch): monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) @@ -144,13 +186,21 @@ def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypat server._sessions["sid"] = _session(agent=agent) resp_effort = server.handle_request( - {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "reasoning", "value": "low"}} + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "reasoning", "value": "low"}, + } ) assert resp_effort["result"]["value"] == "low" assert agent.reasoning_config == {"enabled": True, "effort": "low"} resp_show = server.handle_request( - {"id": "2", "method": "config.set", "params": {"session_id": "sid", "key": "reasoning", "value": "show"}} + { + "id": "2", + "method": "config.set", + "params": {"session_id": "sid", "key": "reasoning", "value": "show"}, + } ) assert resp_show["result"]["value"] == "show" assert server._sessions["sid"]["show_reasoning"] is True @@ -162,7 +212,11 @@ def test_config_set_verbose_updates_session_mode_and_agent(tmp_path, monkeypatch server._sessions["sid"] = _session(agent=agent) resp = server.handle_request( - {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "verbose", "value": "cycle"}} + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "verbose", "value": "cycle"}, + } ) assert resp["result"]["value"] == "verbose" @@ -180,7 +234,11 @@ def test_config_set_model_uses_live_switch_path(monkeypatch): monkeypatch.setattr(server, "_apply_model_switch", _fake_apply) resp = server.handle_request( - {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "new/model"}} + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "model", "value": "new/model"}, + } ) assert resp["result"]["value"] == "new/model" @@ -221,7 +279,15 @@ def test_config_set_model_global_persists(monkeypatch): monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved.update(cfg)) resp = server.handle_request( - {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "anthropic/claude-sonnet-4.6 --global"}} + { + "id": "1", + "method": "config.set", + "params": { + "session_id": "sid", + "key": "model", + "value": "anthropic/claude-sonnet-4.6 --global", + }, + } ) assert resp["result"]["value"] == "anthropic/claude-sonnet-4.6" @@ -241,6 +307,7 @@ def test_config_set_model_syncs_inference_provider_env(monkeypatch): trying openrouter because the env-var-backed resolvers still saw the old provider. """ + class _Agent: provider = "openrouter" model = "old/model" @@ -262,21 +329,39 @@ def test_config_set_model_syncs_inference_provider_env(monkeypatch): server._sessions["sid"] = _session(agent=_Agent()) monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter") - monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **_kwargs: result) + monkeypatch.setattr( + "hermes_cli.model_switch.switch_model", lambda **_kwargs: result + ) monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None) monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) server.handle_request( - {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "claude-sonnet-4.6 --provider anthropic"}} + { + "id": "1", + "method": "config.set", + "params": { + "session_id": "sid", + "key": "model", + "value": "claude-sonnet-4.6 --provider anthropic", + }, + } ) assert os.environ["HERMES_INFERENCE_PROVIDER"] == "anthropic" def test_config_set_personality_rejects_unknown_name(monkeypatch): - monkeypatch.setattr(server, "_available_personalities", lambda cfg=None: {"helpful": "You are helpful."}) + monkeypatch.setattr( + server, + "_available_personalities", + lambda cfg=None: {"helpful": "You are helpful."}, + ) resp = server.handle_request( - {"id": "1", "method": "config.set", "params": {"key": "personality", "value": "bogus"}} + { + "id": "1", + "method": "config.set", + "params": {"key": "personality", "value": "bogus"}, + } ) assert "error" in resp @@ -284,20 +369,36 @@ def test_config_set_personality_rejects_unknown_name(monkeypatch): def test_config_set_personality_resets_history_and_returns_info(monkeypatch): - session = _session(agent=types.SimpleNamespace(), history=[{"role": "user", "text": "hi"}], history_version=4) + session = _session( + agent=types.SimpleNamespace(), + history=[{"role": "user", "text": "hi"}], + history_version=4, + ) new_agent = types.SimpleNamespace(model="x") emits = [] server._sessions["sid"] = session - monkeypatch.setattr(server, "_available_personalities", lambda cfg=None: {"helpful": "You are helpful."}) - monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None: new_agent) - monkeypatch.setattr(server, "_session_info", lambda agent: {"model": getattr(agent, "model", "?")}) + monkeypatch.setattr( + server, + "_available_personalities", + lambda cfg=None: {"helpful": "You are helpful."}, + ) + monkeypatch.setattr( + server, "_make_agent", lambda sid, key, session_id=None: new_agent + ) + monkeypatch.setattr( + server, "_session_info", lambda agent: {"model": getattr(agent, "model", "?")} + ) monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None) monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args)) monkeypatch.setattr(server, "_write_config_key", lambda path, value: None) resp = server.handle_request( - {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "personality", "value": "helpful"}} + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "personality", "value": "helpful"}, + } ) assert resp["result"]["history_reset"] is True @@ -311,11 +412,17 @@ def test_session_compress_uses_compress_helper(monkeypatch): agent = types.SimpleNamespace() server._sessions["sid"] = _session(agent=agent) - monkeypatch.setattr(server, "_compress_session_history", lambda session, focus_topic=None: (2, {"total": 42})) + monkeypatch.setattr( + server, + "_compress_session_history", + lambda session, focus_topic=None: (2, {"total": 42}), + ) monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"}) with patch("tui_gateway.server._emit") as emit: - resp = server.handle_request({"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}) + resp = server.handle_request( + {"id": "1", "method": "session.compress", "params": {"session_id": "sid"}} + ) assert resp["result"]["removed"] == 2 assert resp["result"]["usage"]["total"] == 42 @@ -328,9 +435,14 @@ def test_prompt_submit_sets_approval_session_key(monkeypatch): captured = {} class _Agent: - def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): captured["session_key"] = get_current_session_key(default="") - return {"final_response": "ok", "messages": [{"role": "assistant", "content": "ok"}]} + return { + "final_response": "ok", + "messages": [{"role": "assistant", "content": "ok"}], + } class _ImmediateThread: def __init__(self, target=None, daemon=None): @@ -345,7 +457,13 @@ def test_prompt_submit_sets_approval_session_key(monkeypatch): monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None) monkeypatch.setattr(server, "render_message", lambda raw, cols: None) - resp = server.handle_request({"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "ping"}}) + resp = server.handle_request( + { + "id": "1", + "method": "prompt.submit", + "params": {"session_id": "sid", "text": "ping"}, + } + ) assert resp["result"]["status"] == "streaming" assert captured["session_key"] == "session-key" @@ -359,9 +477,14 @@ def test_prompt_submit_expands_context_refs(monkeypatch): base_url = "" api_key = "" - def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): captured["prompt"] = prompt - return {"final_response": "ok", "messages": [{"role": "assistant", "content": "ok"}]} + return { + "final_response": "ok", + "messages": [{"role": "assistant", "content": "ok"}], + } class _ImmediateThread: def __init__(self, target=None, daemon=None): @@ -371,8 +494,14 @@ def test_prompt_submit_expands_context_refs(monkeypatch): self._target() fake_ctx = types.ModuleType("agent.context_references") - fake_ctx.preprocess_context_references = lambda message, **kwargs: types.SimpleNamespace( - blocked=False, message="expanded prompt", warnings=[], references=[], injected_tokens=0 + fake_ctx.preprocess_context_references = ( + lambda message, **kwargs: types.SimpleNamespace( + blocked=False, + message="expanded prompt", + warnings=[], + references=[], + injected_tokens=0, + ) ) fake_meta = types.ModuleType("agent.model_metadata") fake_meta.get_model_context_length = lambda *args, **kwargs: 100000 @@ -385,7 +514,13 @@ def test_prompt_submit_expands_context_refs(monkeypatch): monkeypatch.setitem(sys.modules, "agent.context_references", fake_ctx) monkeypatch.setitem(sys.modules, "agent.model_metadata", fake_meta) - server.handle_request({"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "@diff"}}) + server.handle_request( + { + "id": "1", + "method": "prompt.submit", + "params": {"session_id": "sid", "text": "@diff"}, + } + ) assert captured["prompt"] == "expanded prompt" @@ -404,7 +539,13 @@ def test_image_attach_appends_local_image(monkeypatch): server._sessions["sid"] = _session() monkeypatch.setitem(sys.modules, "cli", fake_cli) - resp = server.handle_request({"id": "1", "method": "image.attach", "params": {"session_id": "sid", "path": "/tmp/cat.png"}}) + resp = server.handle_request( + { + "id": "1", + "method": "image.attach", + "params": {"session_id": "sid", "path": "/tmp/cat.png"}, + } + ) assert resp["result"]["attached"] is True assert resp["result"]["name"] == "cat.png" @@ -420,14 +561,21 @@ def test_image_attach_accepts_unquoted_screenshot_path_with_spaces(monkeypatch): "is_image": True, "remainder": "", } - fake_cli._split_path_input = lambda raw: ("/tmp/Screenshot", "2026-04-21 at 1.04.43 PM.png") + fake_cli._split_path_input = lambda raw: ( + "/tmp/Screenshot", + "2026-04-21 at 1.04.43 PM.png", + ) fake_cli._resolve_attachment_path = lambda raw: None server._sessions["sid"] = _session() monkeypatch.setitem(sys.modules, "cli", fake_cli) resp = server.handle_request( - {"id": "1", "method": "image.attach", "params": {"session_id": "sid", "path": str(screenshot)}} + { + "id": "1", + "method": "image.attach", + "params": {"session_id": "sid", "path": str(screenshot)}, + } ) assert resp["result"]["attached"] is True @@ -437,20 +585,34 @@ def test_image_attach_accepts_unquoted_screenshot_path_with_spaces(monkeypatch): def test_commands_catalog_surfaces_quick_commands(monkeypatch): - monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": { - "build": {"type": "exec", "command": "npm run build"}, - "git": {"type": "alias", "target": "/shell git"}, - "notes": {"type": "exec", "command": "cat NOTES.md", "description": "Open design notes"}, - }}) + monkeypatch.setattr( + server, + "_load_cfg", + lambda: { + "quick_commands": { + "build": {"type": "exec", "command": "npm run build"}, + "git": {"type": "alias", "target": "/shell git"}, + "notes": { + "type": "exec", + "command": "cat NOTES.md", + "description": "Open design notes", + }, + } + }, + ) - resp = server.handle_request({"id": "1", "method": "commands.catalog", "params": {}}) + resp = server.handle_request( + {"id": "1", "method": "commands.catalog", "params": {}} + ) pairs = dict(resp["result"]["pairs"]) assert "npm run build" in pairs["/build"] assert pairs["/git"].startswith("alias →") assert pairs["/notes"] == "Open design notes" - user_cat = next(c for c in resp["result"]["categories"] if c["name"] == "User commands") + user_cat = next( + c for c in resp["result"]["categories"] if c["name"] == "User commands" + ) user_pairs = dict(user_cat["pairs"]) assert set(user_pairs) == {"/build", "/git", "/notes"} @@ -459,14 +621,22 @@ def test_commands_catalog_surfaces_quick_commands(monkeypatch): def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch): - monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {"boom": {"type": "exec", "command": "boom"}}}) + monkeypatch.setattr( + server, + "_load_cfg", + lambda: {"quick_commands": {"boom": {"type": "exec", "command": "boom"}}}, + ) monkeypatch.setattr( server.subprocess, "run", - lambda *args, **kwargs: types.SimpleNamespace(returncode=1, stdout="", stderr="failed"), + lambda *args, **kwargs: types.SimpleNamespace( + returncode=1, stdout="", stderr="failed" + ), ) - resp = server.handle_request({"id": "1", "method": "command.dispatch", "params": {"name": "boom"}}) + resp = server.handle_request( + {"id": "1", "method": "command.dispatch", "params": {"name": "boom"}} + ) assert "error" in resp assert "failed" in resp["error"]["message"] @@ -474,15 +644,22 @@ def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch): def test_plugins_list_surfaces_loader_error(monkeypatch): with patch("hermes_cli.plugins.get_plugin_manager", side_effect=Exception("boom")): - resp = server.handle_request({"id": "1", "method": "plugins.list", "params": {}}) + resp = server.handle_request( + {"id": "1", "method": "plugins.list", "params": {}} + ) assert "error" in resp assert "boom" in resp["error"]["message"] def test_complete_slash_surfaces_completer_error(monkeypatch): - with patch("hermes_cli.commands.SlashCommandCompleter", side_effect=Exception("no completer")): - resp = server.handle_request({"id": "1", "method": "complete.slash", "params": {"text": "/mo"}}) + with patch( + "hermes_cli.commands.SlashCommandCompleter", + side_effect=Exception("no completer"), + ): + resp = server.handle_request( + {"id": "1", "method": "complete.slash", "params": {"text": "/mo"}} + ) assert "error" in resp assert "no completer" in resp["error"]["message"] @@ -500,7 +677,11 @@ def test_input_detect_drop_attaches_image(monkeypatch): monkeypatch.setitem(sys.modules, "cli", fake_cli) resp = server.handle_request( - {"id": "1", "method": "input.detect_drop", "params": {"session_id": "sid", "text": "/tmp/cat.png"}} + { + "id": "1", + "method": "input.detect_drop", + "params": {"session_id": "sid", "text": "/tmp/cat.png"}, + } ) assert resp["result"]["matched"] is True @@ -521,7 +702,9 @@ def test_rollback_restore_resolves_number_and_file_path(): calls["args"] = (cwd, target, file_path) return {"success": True, "message": "done"} - server._sessions["sid"] = _session(agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=[]) + server._sessions["sid"] = _session( + agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=[] + ) resp = server.handle_request( { "id": "1", @@ -572,7 +755,9 @@ def test_session_steer_calls_agent_steer_when_agent_supports_it(): def test_session_steer_rejects_empty_text(): - server._sessions["sid"] = _session(agent=types.SimpleNamespace(steer=lambda t: True)) + server._sessions["sid"] = _session( + agent=types.SimpleNamespace(steer=lambda t: True) + ) try: resp = server.handle_request( { @@ -632,10 +817,13 @@ def test_session_undo_rejects_while_running(): """Fix for TUI silent-drop #1: /undo must not mutate history while the agent is mid-turn — would either clobber the undo or cause prompt.submit to silently drop the agent's response.""" - server._sessions["sid"] = _session(running=True, history=[ - {"role": "user", "content": "hi"}, - {"role": "assistant", "content": "hello"}, - ]) + server._sessions["sid"] = _session( + running=True, + history=[ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ], + ) try: resp = server.handle_request( {"id": "1", "method": "session.undo", "params": {"session_id": "sid"}} @@ -651,10 +839,13 @@ def test_session_undo_rejects_while_running(): def test_session_undo_allowed_when_idle(): """Regression guard: when not running, /undo still works.""" - server._sessions["sid"] = _session(running=False, history=[ - {"role": "user", "content": "hi"}, - {"role": "assistant", "content": "hello"}, - ]) + server._sessions["sid"] = _session( + running=False, + history=[ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ], + ) try: resp = server.handle_request( {"id": "1", "method": "session.undo", "params": {"session_id": "sid"}} @@ -683,7 +874,11 @@ def test_rollback_restore_rejects_full_history_while_running(monkeypatch): server._sessions["sid"] = _session(running=True) try: resp = server.handle_request( - {"id": "1", "method": "rollback.restore", "params": {"session_id": "sid", "hash": "abc"}} + { + "id": "1", + "method": "rollback.restore", + "params": {"session_id": "sid", "hash": "abc"}, + } ) assert resp.get("error"), "full-history rollback should reject while running" assert resp["error"]["code"] == 4009 @@ -701,12 +896,17 @@ def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch): session_ref = {"s": None} class _RacyAgent: - def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): # Simulate: something external bumped history_version # while we were running. with session_ref["s"]["history_lock"]: session_ref["s"]["history_version"] += 1 - return {"final_response": "agent reply", "messages": [{"role": "assistant", "content": "agent reply"}]} + return { + "final_response": "agent reply", + "messages": [{"role": "assistant", "content": "agent reply"}], + } class _ImmediateThread: def __init__(self, target=None, daemon=None): @@ -725,7 +925,11 @@ def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch): monkeypatch.setattr(server, "_emit", lambda *a: emits.append(a)) resp = server.handle_request( - {"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "hi"}} + { + "id": "1", + "method": "prompt.submit", + "params": {"session_id": "sid", "text": "hi"}, + } ) assert resp.get("result"), f"got error: {resp.get('error')}" @@ -742,16 +946,25 @@ def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch): "history_version mismatch — otherwise the UI silently " "shows output that was never persisted" ) - assert "not saved" in payload["warning"].lower() or "changed" in payload["warning"].lower() + assert ( + "not saved" in payload["warning"].lower() + or "changed" in payload["warning"].lower() + ) finally: server._sessions.pop("sid", None) def test_prompt_submit_history_version_match_persists_normally(monkeypatch): """Regression guard: the backstop does not affect the happy path.""" + class _Agent: - def run_conversation(self, prompt, conversation_history=None, stream_callback=None): - return {"final_response": "reply", "messages": [{"role": "assistant", "content": "reply"}]} + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): + return { + "final_response": "reply", + "messages": [{"role": "assistant", "content": "reply"}], + } class _ImmediateThread: def __init__(self, target=None, daemon=None): @@ -769,12 +982,18 @@ def test_prompt_submit_history_version_match_persists_normally(monkeypatch): monkeypatch.setattr(server, "_emit", lambda *a: emits.append(a)) resp = server.handle_request( - {"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "hi"}} + { + "id": "1", + "method": "prompt.submit", + "params": {"session_id": "sid", "text": "hi"}, + } ) assert resp.get("result") # History was written - assert server._sessions["sid"]["history"] == [{"role": "assistant", "content": "reply"}] + assert server._sessions["sid"]["history"] == [ + {"role": "assistant", "content": "reply"} + ] assert server._sessions["sid"]["history_version"] == 1 # No warning should be attached @@ -818,7 +1037,11 @@ def test_interrupt_only_clears_own_session_pending(): # Interrupt session A. resp = server.handle_request( - {"id": "1", "method": "session.interrupt", "params": {"session_id": "sid_a"}} + { + "id": "1", + "method": "session.interrupt", + "params": {"session_id": "sid_a"}, + } ) assert resp.get("result"), f"got error: {resp.get('error')}" @@ -891,8 +1114,11 @@ def test_respond_unpacks_sid_tuple_correctly(): server._pending["rid-x"] = ("sid_x", ev) try: resp = server.handle_request( - {"id": "1", "method": "clarify.respond", - "params": {"request_id": "rid-x", "answer": "the answer"}} + { + "id": "1", + "method": "clarify.respond", + "params": {"request_id": "rid-x", "answer": "the answer"}, + } ) assert resp.get("result") assert ev.is_set() @@ -902,7 +1128,6 @@ def test_respond_unpacks_sid_tuple_correctly(): server._answers.pop("rid-x", None) - # --------------------------------------------------------------------------- # /model switch and other agent-mutating commands must reject while the # session is running. agent.switch_model() mutates self.model, self.provider, @@ -925,10 +1150,17 @@ def test_config_set_model_rejects_while_running(monkeypatch): server._sessions["sid"] = _session(running=True) try: - resp = server.handle_request({ - "id": "1", "method": "config.set", - "params": {"session_id": "sid", "key": "model", "value": "anthropic/claude-sonnet-4.6"}, - }) + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": { + "session_id": "sid", + "key": "model", + "value": "anthropic/claude-sonnet-4.6", + }, + } + ) assert resp.get("error") assert resp["error"]["code"] == 4009 assert "session busy" in resp["error"]["message"] @@ -952,10 +1184,13 @@ def test_config_set_model_allowed_when_idle(monkeypatch): server._sessions["sid"] = _session(running=False) try: - resp = server.handle_request({ - "id": "1", "method": "config.set", - "params": {"session_id": "sid", "key": "model", "value": "newmodel"}, - }) + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "model", "value": "newmodel"}, + } + ) assert resp.get("result") assert resp["result"]["value"] == "newmodel" assert seen["called"] @@ -993,9 +1228,9 @@ def test_mirror_slash_side_effects_rejects_mutating_commands_while_running(monke ("/compress", "compress"), ]: warning = server._mirror_slash_side_effects("sid", session, cmd) - assert "session busy" in warning, ( - f"{cmd} should have returned busy warning, got: {warning!r}" - ) + assert ( + "session busy" in warning + ), f"{cmd} should have returned busy warning, got: {warning!r}" assert f"/{expected_name}" in warning # None of the mutating side-effect helpers should have fired. @@ -1068,7 +1303,11 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch): # Stub everything _build touches monkeypatch.setattr(server, "_make_agent", _slow_make_agent) monkeypatch.setattr(server, "_SlashWorker", _FakeWorker) - monkeypatch.setattr(server, "_get_db", lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None)) + monkeypatch.setattr( + server, + "_get_db", + lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None), + ) monkeypatch.setattr(server, "_session_info", lambda _a: {"model": "x"}) monkeypatch.setattr(server, "_probe_credentials", lambda _a: None) monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None) @@ -1076,25 +1315,36 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch): # Shim register/unregister to observe leaks import tools.approval as _approval - monkeypatch.setattr(_approval, "register_gateway_notify", - lambda key, cb: None) - monkeypatch.setattr(_approval, "unregister_gateway_notify", - lambda key: unregistered_keys.append(key)) + + monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None) + monkeypatch.setattr( + _approval, + "unregister_gateway_notify", + lambda key: unregistered_keys.append(key), + ) monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None) # Start: session.create spawns _build thread, returns synchronously - resp = server.handle_request({ - "id": "1", "method": "session.create", "params": {"cols": 80}, - }) + resp = server.handle_request( + { + "id": "1", + "method": "session.create", + "params": {"cols": 80}, + } + ) assert resp.get("result"), f"got error: {resp.get('error')}" sid = resp["result"]["session_id"] # Build thread is blocked in _slow_make_agent. Close the session # NOW — this pops _sessions[sid] before _build can install the # worker/notify. - close_resp = server.handle_request({ - "id": "2", "method": "session.close", "params": {"session_id": sid}, - }) + close_resp = server.handle_request( + { + "id": "2", + "method": "session.close", + "params": {"session_id": sid}, + } + ) assert close_resp.get("result", {}).get("closed") is True # At this point session.close saw slash_worker=None (not yet @@ -1108,11 +1358,12 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch): if closed_workers: break import time + time.sleep(0.02) - assert len(closed_workers) == 1, ( - f"orphan worker was not cleaned up — closed_workers={closed_workers}" - ) + assert ( + len(closed_workers) == 1 + ), f"orphan worker was not cleaned up — closed_workers={closed_workers}" # Notify may be unregistered by both session.close (unconditional) # and the orphan-cleanup path; the key guarantee is that the build # thread does at least one unregister call (any prior close @@ -1146,21 +1397,33 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch): monkeypatch.setattr(server, "_make_agent", lambda sid, key: _FakeAgent()) monkeypatch.setattr(server, "_SlashWorker", _FakeWorker) - monkeypatch.setattr(server, "_get_db", lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None)) + monkeypatch.setattr( + server, + "_get_db", + lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None), + ) monkeypatch.setattr(server, "_session_info", lambda _a: {"model": "x"}) monkeypatch.setattr(server, "_probe_credentials", lambda _a: None) monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None) monkeypatch.setattr(server, "_emit", lambda *a, **kw: None) import tools.approval as _approval + monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None) - monkeypatch.setattr(_approval, "unregister_gateway_notify", - lambda key: unregistered_keys.append(key)) + monkeypatch.setattr( + _approval, + "unregister_gateway_notify", + lambda key: unregistered_keys.append(key), + ) monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None) - resp = server.handle_request({ - "id": "1", "method": "session.create", "params": {"cols": 80}, - }) + resp = server.handle_request( + { + "id": "1", + "method": "session.create", + "params": {"cols": 80}, + } + ) sid = resp["result"]["session_id"] # Wait for the build to finish (ready event inside session dict). @@ -1169,12 +1432,12 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch): # Build finished without a close race — nothing should have been # cleaned up by the orphan check. - assert closed_workers == [], ( - f"build thread closed its own worker despite no race: {closed_workers}" - ) - assert unregistered_keys == [], ( - f"build thread unregistered its own notify despite no race: {unregistered_keys}" - ) + assert ( + closed_workers == [] + ), f"build thread closed its own worker despite no race: {closed_workers}" + assert ( + unregistered_keys == [] + ), f"build thread unregistered its own notify despite no race: {unregistered_keys}" # Session should have the live worker installed. assert session.get("slash_worker") is not None diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 49cd9660b..7e0bef9a1 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2528,7 +2528,8 @@ def _(rid, params: dict) -> dict: if key == "statusbar": raw = str(value or "").strip().lower() - d0 = _load_cfg().get("display") or {} + cfg0 = _load_cfg() + d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {} current = _coerce_statusbar(d0.get("tui_statusbar", "top")) if raw in ("", "toggle"): @@ -2659,7 +2660,10 @@ 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", "top") + display = _load_cfg().get("display") + raw = ( + display.get("tui_statusbar", "top") if isinstance(display, dict) else "top" + ) return _ok(rid, {"value": _coerce_statusbar(raw)}) if key == "mtime": cfg_path = _hermes_home / "config.yaml" diff --git a/ui-tui/src/__tests__/useConfigSync.test.ts b/ui-tui/src/__tests__/useConfigSync.test.ts index b5b25ddd8..c5a0a97dc 100644 --- a/ui-tui/src/__tests__/useConfigSync.test.ts +++ b/ui-tui/src/__tests__/useConfigSync.test.ts @@ -105,4 +105,11 @@ describe('normalizeStatusBar', () => { expect(normalizeStatusBar('sideways')).toBe('top') expect(normalizeStatusBar(42)).toBe('top') }) + + it('trims whitespace and folds case', () => { + expect(normalizeStatusBar(' Bottom ')).toBe('bottom') + expect(normalizeStatusBar('TOP')).toBe('top') + expect(normalizeStatusBar(' on ')).toBe('top') + expect(normalizeStatusBar('OFF')).toBe('off') + }) }) diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index f50dcbd10..fb0e679a1 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -16,12 +16,20 @@ import { patchUiState } from './uiStore.js' const STATUSBAR_MODES = new Set(['bottom', 'off', 'top']) -export const normalizeStatusBar = (raw: unknown): StatusBarMode => - raw === false - ? 'off' - : typeof raw === 'string' && STATUSBAR_MODES.has(raw as StatusBarMode) - ? (raw as StatusBarMode) - : 'top' +export const normalizeStatusBar = (raw: unknown): StatusBarMode => { + if (raw === false) { + return 'off' + } + + if (typeof raw !== 'string') { + return 'top' + } + + const v = raw.trim().toLowerCase() + const mode = (v === 'on' ? 'top' : v) as StatusBarMode + + return STATUSBAR_MODES.has(mode) ? mode : 'top' +} const MTIME_POLL_MS = 5000 diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 07f241cb5..715d775ee 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -380,9 +380,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { // shift-tab flips yolo without spending a turn (claude-code parity) if (key.shift && key.tab && !cState.completions.length) { + if (!live.sid) { + return void actions.sys('yolo needs an active session') + } + return void gateway .rpc('config.set', { key: 'yolo', session_id: live.sid }) - .then(r => actions.sys(`yolo ${r?.value === '1' ? 'on' : 'off'}`)) + .then(r => actions.sys(r ? `yolo ${r.value === '1' ? 'on' : 'off'}` : 'failed to toggle yolo')) + .catch(() => actions.sys('failed to toggle yolo')) } if (key.tab && cState.completions.length) { From 3ef6992edf2b7f09a2b91976b5e5647e1ca77a47 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 15:04:24 -0500 Subject: [PATCH 10/16] fix(tui): drop main-screen banner flash, widen alt-screen clear on entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - entry.tsx no longer writes bootBanner() to the main screen before the alt-screen enters. The renders inside the alt screen via the seeded intro row, so nothing is lost — just the flash that preceded it. Fixes the torn first frame reported on Alacritty (blitz row 5 #17) and shaves the 'starting agent' hang perception (row 5 #1) since the UI paints straight into the steady-state view - AlternateScreen prefixes ERASE_SCROLLBACK (\x1b[3J) to its entry so strict emulators start from a pristine grid; named constants replace the inline sequences for clarity - bootBanner.ts deleted — dead code --- .../src/ink/components/AlternateScreen.tsx | 5 +++- ui-tui/src/bootBanner.ts | 26 ------------------- ui-tui/src/entry.tsx | 3 --- 3 files changed, 4 insertions(+), 30 deletions(-) delete mode 100644 ui-tui/src/bootBanner.ts diff --git a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx index bb1860817..f135d70c6 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx @@ -2,6 +2,7 @@ import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'r import { c as _c } from 'react/compiler-runtime' import instances from '../instances.js' +import { CURSOR_HOME, ERASE_SCREEN, ERASE_SCROLLBACK } from '../termio/csi.js' import { DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from '../termio/dec.js' import { TerminalWriteContext } from '../useTerminalNotification.js' @@ -51,7 +52,9 @@ export function AlternateScreen(t0: Props) { return } - writeRaw(ENTER_ALT_SCREEN + '\x1B[2J\x1B[H' + (mouseTracking ? ENABLE_MOUSE_TRACKING : '')) + writeRaw( + ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + (mouseTracking ? ENABLE_MOUSE_TRACKING : '') + ) ink?.setAltScreenActive(true, mouseTracking) return () => { diff --git a/ui-tui/src/bootBanner.ts b/ui-tui/src/bootBanner.ts deleted file mode 100644 index 2c85387bd..000000000 --- a/ui-tui/src/bootBanner.ts +++ /dev/null @@ -1,26 +0,0 @@ -const GOLD = '\x1b[38;2;255;215;0m' -const AMBER = '\x1b[38;2;255;191;0m' -const BRONZE = '\x1b[38;2;205;127;50m' -const DIM = '\x1b[38;2;184;134;11m' -const RESET = '\x1b[0m' - -const LOGO = [ - '██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗', - '██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝', - '███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ', - '██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ', - '██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ', - '╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ' -] - -const GRADIENT = [GOLD, GOLD, AMBER, AMBER, BRONZE, BRONZE] as const -const LOGO_WIDTH = 98 - -const TAGLINE = `${DIM}⚕ Nous Research · Messenger of the Digital Gods${RESET}` -const FALLBACK = `\x1b[1m${GOLD}⚕ NOUS HERMES${RESET}` - -export function bootBanner(cols: number = process.stdout.columns || 80): string { - const body = cols >= LOGO_WIDTH ? LOGO.map((text, i) => `${GRADIENT[i]}${text}${RESET}`).join('\n') : FALLBACK - - return `\n${body}\n${TAGLINE}\n\n` -} diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index 6f1506e5a..8fdf9f68f 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -1,5 +1,4 @@ #!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc -import { bootBanner } from './bootBanner.js' import { GatewayClient } from './gatewayClient.js' import { setupGracefulExit } from './lib/gracefulExit.js' import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js' @@ -10,8 +9,6 @@ if (!process.stdin.isTTY) { process.exit(0) } -process.stdout.write(bootBanner()) - const gw = new GatewayClient() gw.start() From b641639e425bfd26dbe3edbd113d8749384cbf40 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 15:22:33 -0500 Subject: [PATCH 11/16] fix(debug): distinguish empty-log from missing-log in report placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot on #14138 flagged that the share report says '(file not found)' when the log exists but is empty (either because the primary is empty and no .1 rotation exists, or in the rare race where the file is truncated between _resolve_log_path() and stat()). - Split _primary_log_path() out of _resolve_log_path so both can share the LOG_FILES/home math without duplication. - _capture_log_snapshot now reports '(file empty)' when the primary path exists on disk with zero bytes, and keeps '(file not found)' for the truly-missing case. Tests: rename test_returns_none_for_empty → test_empty_primary_reports_file_empty with the new assertion, plus a race-path test that monkeypatches _resolve_log_path to exercise the size==0 branch directly. --- hermes_cli/debug.py | 32 ++++++++++++++++++++------------ tests/hermes_cli/test_debug.py | 19 ++++++++++++++++--- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/hermes_cli/debug.py b/hermes_cli/debug.py index 2d7fbd207..d5947be82 100644 --- a/hermes_cli/debug.py +++ b/hermes_cli/debug.py @@ -332,24 +332,29 @@ class LogSnapshot: tail_text: str full_text: Optional[str] -def _resolve_log_path(log_name: str) -> Optional[Path]: - """Find the log file for *log_name*, falling back to the .1 rotation. - - Returns the path if found, or None. - """ +def _primary_log_path(log_name: str) -> Optional[Path]: + """Where *log_name* would live if present. Doesn't check existence.""" from hermes_cli.logs import LOG_FILES filename = LOG_FILES.get(log_name) - if not filename: + return (get_hermes_home() / "logs" / filename) if filename else None + + +def _resolve_log_path(log_name: str) -> Optional[Path]: + """Find the log file for *log_name*, falling back to the .1 rotation. + + Returns the first non-empty candidate (primary, then .1), or None. + Callers distinguish 'empty primary' from 'truly missing' via + :func:`_primary_log_path`. + """ + primary = _primary_log_path(log_name) + if primary is None: return None - log_dir = get_hermes_home() / "logs" - primary = log_dir / filename if primary.exists() and primary.stat().st_size > 0: return primary - # Fall back to the most recent rotated file (.1). - rotated = log_dir / f"{filename}.1" + rotated = primary.parent / f"{primary.name}.1" if rotated.exists() and rotated.stat().st_size > 0: return rotated @@ -370,12 +375,15 @@ def _capture_log_snapshot( """ log_path = _resolve_log_path(log_name) if log_path is None: - return LogSnapshot(path=None, tail_text="(file not found)", full_text=None) + primary = _primary_log_path(log_name) + tail = "(file empty)" if primary and primary.exists() else "(file not found)" + return LogSnapshot(path=None, tail_text=tail, full_text=None) try: size = log_path.stat().st_size if size == 0: - return LogSnapshot(path=log_path, tail_text="(file not found)", full_text=None) + # race: file was truncated between _resolve_log_path and stat + return LogSnapshot(path=log_path, tail_text="(file empty)", full_text=None) with open(log_path, "rb") as f: if size <= max_bytes: diff --git a/tests/hermes_cli/test_debug.py b/tests/hermes_cli/test_debug.py index 91795151b..4bba56867 100644 --- a/tests/hermes_cli/test_debug.py +++ b/tests/hermes_cli/test_debug.py @@ -158,14 +158,27 @@ class TestCaptureLogSnapshot: assert snap.full_text is None assert snap.tail_text == "(file not found)" - def test_returns_none_for_empty(self, hermes_home): - # Truncate agent.log to empty + def test_empty_primary_reports_file_empty(self, hermes_home): + """Empty primary (no .1 fallback) surfaces as '(file empty)', not missing.""" (hermes_home / "logs" / "agent.log").write_text("") from hermes_cli.debug import _capture_log_snapshot snap = _capture_log_snapshot("agent", tail_lines=10) assert snap.full_text is None - assert snap.tail_text == "(file not found)" + assert snap.tail_text == "(file empty)" + + def test_race_truncate_after_resolve_reports_empty(self, hermes_home, monkeypatch): + """If the log is truncated between resolve and stat, say 'empty', not 'missing'.""" + log_path = hermes_home / "logs" / "agent.log" + from hermes_cli import debug + + monkeypatch.setattr(debug, "_resolve_log_path", lambda _name: log_path) + log_path.write_text("") + + snap = debug._capture_log_snapshot("agent", tail_lines=10) + assert snap.path == log_path + assert snap.full_text is None + assert snap.tail_text == "(file empty)" def test_truncates_large_file(self, hermes_home): """Files larger than max_bytes get tail-truncated.""" From e0d698cfb351780ee62e16abfe52a0d6a87cd707 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 15:51:11 -0500 Subject: [PATCH 12/16] fix(tui): yolo toggle only reports on/off for strict '0'/'1' values Copilot on #14145 flagged that the shift+tab yolo handler treated any non-null RPC result as valid, so a response shape like {value: undefined} or {value: 'weird'} would incorrectly echo 'yolo off'. Now only '1' and '0' map to on/off; anything else (including missing value) surfaces as 'failed to toggle yolo', matching the null/catch branches. --- ui-tui/src/app/useInputHandlers.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 715d775ee..1d9e834f9 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -386,7 +386,11 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return void gateway .rpc('config.set', { key: 'yolo', session_id: live.sid }) - .then(r => actions.sys(r ? `yolo ${r.value === '1' ? 'on' : 'off'}` : 'failed to toggle yolo')) + .then(r => + actions.sys( + r?.value === '1' ? 'yolo on' : r?.value === '0' ? 'yolo off' : 'failed to toggle yolo' + ) + ) .catch(() => actions.sys('failed to toggle yolo')) } From 8410ac05a9cccee981aebf649233064df528e752 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 16:27:44 -0500 Subject: [PATCH 13/16] fix(tui): tab title shows cwd + waiting-for-input marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the terminal tab title was `{⏳/✓} {model} — Hermes` which only distinguished busy vs idle. Users juggling multiple Hermes tabs had no way to tell which one was waiting on them for approval/clarify/sudo/ secret, and no cue for which workspace the tab was attached to. - 3-state marker: `⚠` when an overlay prompt is open, `⏳` busy, `✓` idle. - Append `· {shortCwd}` (28-char budget, $HOME → ~) so the tab surfaces the workspace directly. - Drop the `— Hermes` suffix — the marker already signals what this is, and tab titles are tight. --- ui-tui/src/app/useMainApp.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index a415d3437..2a25aacf7 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { STARTUP_RESUME_ID } from '../config/env.js' import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' -import { fmtCwdBranch } from '../domain/paths.js' +import { fmtCwdBranch, shortCwd } from '../domain/paths.js' import { type GatewayClient } from '../gatewayClient.js' import type { ClarifyRespondResponse, @@ -315,10 +315,14 @@ export function useMainApp(gw: GatewayClient) { useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid }) // ── Terminal tab title ───────────────────────────────────────────── - // Show model name + status so users can identify the Hermes tab. + // model + cwd + 3-state marker so multi-instance users can spot which tab + // is working, which is idle, and which is waiting on them. + // `⚠` blocked on approval/sudo/secret/clarify, `⏳` busy, `✓` idle. const shortModel = ui.info?.model?.replace(/^.*\//, '') ?? '' - const titleStatus = ui.busy ? '⏳' : '✓' - const terminalTitle = shortModel ? `${titleStatus} ${shortModel} — Hermes` : 'Hermes' + const blockedOnInput = !!(overlay.approval || overlay.sudo || overlay.secret || overlay.clarify) + const titleStatus = blockedOnInput ? '⚠' : ui.busy ? '⏳' : '✓' + const cwdTag = ui.info?.cwd ? ` · ${shortCwd(ui.info.cwd, 24)}` : '' + const terminalTitle = shortModel ? `${titleStatus} ${shortModel}${cwdTag}` : 'Hermes' useTerminalTitle(terminalTitle) useEffect(() => { From 103c71ac36c6ebbf929ecf52daaef02350296c09 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 16:32:48 -0500 Subject: [PATCH 14/16] =?UTF-8?q?refactor(tui):=20/clean=20pass=20on=20tui?= =?UTF-8?q?-polish=20=E2=80=94=20data=20tables,=20tighter=20title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - normalizeStatusBar: replace Set + early-returns + cast with a single alias lookup table. Handles legacy `false`, trims/lowercases strings, maps `on` → `top` in one pass. One expression, no `as` hacks. - Tab title block: drop the narrative comment, fold blockedOnInput/titleStatus/cwdTag/terminalTitle into inline expressions inside useTerminalTitle. Avoids shadowing the outer `cwd`. - tui_gateway statusbar set branch: read `display` once instead of `cfg0.get("display")` twice. --- tui_gateway/server.py | 4 ++-- ui-tui/src/app/useConfigSync.ts | 23 ++++++++--------------- ui-tui/src/app/useMainApp.ts | 21 ++++++++++----------- 3 files changed, 20 insertions(+), 28 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7e0bef9a1..3aac77192 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2528,8 +2528,8 @@ def _(rid, params: dict) -> dict: if key == "statusbar": raw = str(value or "").strip().lower() - cfg0 = _load_cfg() - d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {} + display = _load_cfg().get("display") + d0 = display if isinstance(display, dict) else {} current = _coerce_statusbar(d0.get("tui_statusbar", "top")) if raw in ("", "toggle"): diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index fb0e679a1..63b0100cc 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -14,23 +14,16 @@ import type { StatusBarMode } from './interfaces.js' import { turnController } from './turnController.js' import { patchUiState } from './uiStore.js' -const STATUSBAR_MODES = new Set(['bottom', 'off', 'top']) - -export const normalizeStatusBar = (raw: unknown): StatusBarMode => { - if (raw === false) { - return 'off' - } - - if (typeof raw !== 'string') { - return 'top' - } - - const v = raw.trim().toLowerCase() - const mode = (v === 'on' ? 'top' : v) as StatusBarMode - - return STATUSBAR_MODES.has(mode) ? mode : 'top' +const STATUSBAR_ALIAS: Record = { + bottom: 'bottom', + off: 'off', + on: 'top', + top: 'top' } +export const normalizeStatusBar = (raw: unknown): StatusBarMode => + raw === false ? 'off' : typeof raw === 'string' ? STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top' : 'top' + const MTIME_POLL_MS = 5000 const quietRpc = async = Record>( diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 2a25aacf7..36b1e0179 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -1,4 +1,4 @@ -import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink' +import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle, type ScrollBoxHandle } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -314,16 +314,15 @@ export function useMainApp(gw: GatewayClient) { useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid }) - // ── Terminal tab title ───────────────────────────────────────────── - // model + cwd + 3-state marker so multi-instance users can spot which tab - // is working, which is idle, and which is waiting on them. - // `⚠` blocked on approval/sudo/secret/clarify, `⏳` busy, `✓` idle. - const shortModel = ui.info?.model?.replace(/^.*\//, '') ?? '' - const blockedOnInput = !!(overlay.approval || overlay.sudo || overlay.secret || overlay.clarify) - const titleStatus = blockedOnInput ? '⚠' : ui.busy ? '⏳' : '✓' - const cwdTag = ui.info?.cwd ? ` · ${shortCwd(ui.info.cwd, 24)}` : '' - const terminalTitle = shortModel ? `${titleStatus} ${shortModel}${cwdTag}` : 'Hermes' - useTerminalTitle(terminalTitle) + // Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle. + const model = ui.info?.model?.replace(/^.*\//, '') ?? '' + const marker = + overlay.approval || overlay.sudo || overlay.secret || overlay.clarify ? '⚠' : ui.busy ? '⏳' : '✓' + const tabCwd = ui.info?.cwd + + useTerminalTitle( + model ? `${marker} ${model}${tabCwd ? ` · ${shortCwd(tabCwd, 24)}` : ''}` : 'Hermes' + ) useEffect(() => { if (!ui.sid || !stdout) { From 4107538da8304066892e11dbb26388ca65c679f6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 16:34:05 -0500 Subject: [PATCH 15/16] style(debug): add missing blank line between LogSnapshot and helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot on #14145 flagged PEP 8 / Black convention — two blank lines between top-level class and next top-level function. --- hermes_cli/debug.py | 1 + ui-tui/src/__tests__/subagentTree.test.ts | 5 +---- ui-tui/src/app/useConfigSync.ts | 2 +- ui-tui/src/app/useInputHandlers.ts | 6 +----- ui-tui/src/app/useMainApp.ts | 11 +++++------ ui-tui/src/components/appChrome.tsx | 4 ++-- 6 files changed, 11 insertions(+), 18 deletions(-) diff --git a/hermes_cli/debug.py b/hermes_cli/debug.py index d5947be82..8915d8a6a 100644 --- a/hermes_cli/debug.py +++ b/hermes_cli/debug.py @@ -332,6 +332,7 @@ class LogSnapshot: tail_text: str full_text: Optional[str] + def _primary_log_path(log_name: str) -> Optional[Path]: """Where *log_name* would live if present. Doesn't check existence.""" from hermes_cli.logs import LOG_FILES diff --git a/ui-tui/src/__tests__/subagentTree.test.ts b/ui-tui/src/__tests__/subagentTree.test.ts index 887754ce0..bd892d7ac 100644 --- a/ui-tui/src/__tests__/subagentTree.test.ts +++ b/ui-tui/src/__tests__/subagentTree.test.ts @@ -395,10 +395,7 @@ describe('topLevelSubagents', () => { }) it('excludes children whose parent is present', () => { - const items = [ - makeItem({ id: 'p', index: 0 }), - makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' }) - ] + const items = [makeItem({ id: 'p', index: 0 }), makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' })] expect(topLevelSubagents(items).map(s => s.id)).toEqual(['p']) }) diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 63b0100cc..9e7c93ce9 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -22,7 +22,7 @@ const STATUSBAR_ALIAS: Record = { } export const normalizeStatusBar = (raw: unknown): StatusBarMode => - raw === false ? 'off' : typeof raw === 'string' ? STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top' : 'top' + raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top' const MTIME_POLL_MS = 5000 diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 1d9e834f9..211eb8396 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -386,11 +386,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return void gateway .rpc('config.set', { key: 'yolo', session_id: live.sid }) - .then(r => - actions.sys( - r?.value === '1' ? 'yolo on' : r?.value === '0' ? 'yolo off' : 'failed to toggle yolo' - ) - ) + .then(r => actions.sys(r?.value === '1' ? 'yolo on' : r?.value === '0' ? 'yolo off' : 'failed to toggle yolo')) .catch(() => actions.sys('failed to toggle yolo')) } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 36b1e0179..39c4b534c 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -1,4 +1,4 @@ -import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle, type ScrollBoxHandle } from '@hermes/ink' +import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -316,13 +316,12 @@ export function useMainApp(gw: GatewayClient) { // Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle. const model = ui.info?.model?.replace(/^.*\//, '') ?? '' - const marker = - overlay.approval || overlay.sudo || overlay.secret || overlay.clarify ? '⚠' : ui.busy ? '⏳' : '✓' + + const marker = overlay.approval || overlay.sudo || overlay.secret || overlay.clarify ? '⚠' : ui.busy ? '⏳' : '✓' + const tabCwd = ui.info?.cwd - useTerminalTitle( - model ? `${marker} ${model}${tabCwd ? ` · ${shortCwd(tabCwd, 24)}` : ''}` : 'Hermes' - ) + useTerminalTitle(model ? `${marker} ${model}${tabCwd ? ` · ${shortCwd(tabCwd, 24)}` : ''}` : 'Hermes') useEffect(() => { if (!ui.sid || !stdout) { diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index b9b5e9450..d12a4debf 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -1,6 +1,6 @@ -import { Box, Text, type ScrollBoxHandle } from '@hermes/ink' +import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' -import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type ReactNode, type RefObject } from 'react' +import { type ReactNode, type RefObject, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react' import { $delegationState } from '../app/delegationStore.js' import { $turnState } from '../app/turnStore.js' From 83efea661f83519921556fc9945388118f04a154 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Apr 2026 16:48:03 -0500 Subject: [PATCH 16/16] fix(tui): address copilot round 3 on #14145 - appLayout.tsx: restore the 1-row placeholder when `showStickyPrompt` is false. Dropping it saved a row but the composer height shifted by one as the prompt appeared/disappeared, jumping the input vertically on scroll. - useInputHandlers: gateway.rpc (from useMainApp) already catches errors with its own sys() message and resolves to null. The previous `.catch` was dead code and on RPC failures the user saw both 'error: ...' (from rpc) and 'failed to toggle yolo'. Drop the catch and gate 'failed to toggle yolo' on a non-null response so null (= rpc already spoke) stays silent. --- ui-tui/src/app/useInputHandlers.ts | 19 +++++++++++++++---- ui-tui/src/components/appLayout.tsx | 4 +++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 211eb8396..72cd5b9e5 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -384,10 +384,21 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return void actions.sys('yolo needs an active session') } - return void gateway - .rpc('config.set', { key: 'yolo', session_id: live.sid }) - .then(r => actions.sys(r?.value === '1' ? 'yolo on' : r?.value === '0' ? 'yolo off' : 'failed to toggle yolo')) - .catch(() => actions.sys('failed to toggle yolo')) + // gateway.rpc swallows errors with its own sys() message and resolves to null, + // so we only speak when it came back with a real shape. null = rpc already spoke. + return void gateway.rpc('config.set', { key: 'yolo', session_id: live.sid }).then(r => { + if (r?.value === '1') { + return actions.sys('yolo on') + } + + if (r?.value === '0') { + return actions.sys('yolo off') + } + + if (r) { + actions.sys('failed to toggle yolo') + } + }) } if (key.tab && cState.completions.length) { diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index c96960ac8..cdac992d3 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -173,12 +173,14 @@ const ComposerPane = memo(function ComposerPane({ )} - {status.showStickyPrompt && ( + {status.showStickyPrompt ? ( {status.stickyPrompt} + ) : ( + )}