opentui(v6): HERMES_TUI_DIAGNOSTICS master switch — gate /mem, /heapdump + window-stats default

Regular users get zero diagnostic surface by default: /mem and /heapdump
disappear from /help and completion, and invoking them prints the
one-line enable hint (relaunch with HERMES_TUI_DIAGNOSTICS=1) instead of
executing — an enable switch, not a secret. With the switch on, the
commands work as before and HERMES_TUI_WINDOW_STATS defaults on (still
individually settable either way). Full env-flag ledger (master switch /
user config / dev tuning / internal plumbing) in docs/opentui-env-flags.md.
672 tests exit 0.
This commit is contained in:
alt-glitch 2026-06-12 09:17:25 +05:30
parent fc8d5f203a
commit cf3002664b
6 changed files with 152 additions and 10 deletions

57
docs/opentui-env-flags.md Normal file
View file

@ -0,0 +1,57 @@
# OpenTUI env flags — the consolidated ledger
Every environment variable the OpenTUI TUI reads (grep-verified 2026-06-12),
classified by who should ever touch it. The design rule shipped with this doc:
**regular users see zero diagnostic surface by default; one master switch
(`HERMES_TUI_DIAGNOSTICS=1`) turns all of it on when needed.**
## 1. The master switch
| var | default | effect |
|---|---|---|
| `HERMES_TUI_DIAGNOSTICS` | **off** | Enables the diagnostic slash commands (`/mem`, `/heapdump`) — hidden from `/help` and completion otherwise, and invoking them while off prints the enable hint rather than executing. Also flips the *default* of `HERMES_TUI_WINDOW_STATS` to on. Not a secret — support flows are "relaunch with `HERMES_TUI_DIAGNOSTICS=1`". |
## 2. User-facing configuration (fine to document publicly)
| var | default | effect |
|---|---|---|
| `HERMES_TUI_ENGINE` | auto (`opentui` if Node≥26.3 + built, else `ink`) | Engine pick; also `display.tui_engine` in config.yaml. |
| `HERMES_TUI_MOUSE` | on (launcher sets it) | Mouse support (wheel scroll, selection, click-to-expand). |
| `HERMES_TUI_MAX_MESSAGES` | ceiling | Scrollback rows kept in the TUI. Can LOWER the ceiling, never raise: 3000 with windowing, 1000 with windowing off (handle-table safety). |
| `HERMES_TUI_TOOL_OUTPUT_LINES` | unlimited | Cap expanded tool-output lines (set a number to restore a cap). |
| `HERMES_TUI_COMPOSER_ROWS` | default rows | Composer height. |
## 3. Escape hatches & tuning (dev-facing, individually settable)
| var | default | effect |
|---|---|---|
| `HERMES_TUI_WINDOWING` | **on** | `0` = bit-exact pre-windowing renderer (every row mounts; cap clamps back to 1000). The A/B + regression escape hatch. |
| `HERMES_TUI_WINDOW_IDLE_MS` | ~1000 | Idle-measure pulse cadence (the spacer-exactness march). Test knob. |
| `HERMES_TUI_WINDOW_STATS` | = `HERMES_TUI_DIAGNOSTICS` | Exposes live/peak mounted-row counters (`globalThis.__hermesTuiWindowStats`) for bench/live-attach reads. |
| `HERMES_TUI_LOG_LEVEL` / `HERMES_TUI_LOG_FILE` | engine defaults | Logging verbosity/destination (`/logs` reads the ring buffer regardless). Deliberately independent of the master switch — support often wants logs without the full diag surface. |
## 4. Internal plumbing (set by the launcher/bench/tests — humans never set these)
| var | set by | effect |
|---|---|---|
| `HERMES_PYTHON`, `HERMES_PYTHON_SRC_ROOT`, `HERMES_CWD` | launcher / bench | Which gateway python + repo root + cwd the TUI spawns against (the bench's fake-gateway seam). |
| `HERMES_TUI_ACTIVE_SESSION_FILE` | launcher/bench | Session handoff file. |
| `HERMES_TUI_RESUME`, `HERMES_TUI_PROMPT`, `HERMES_TUI_FAKE` | launcher/tests | Resume-at-boot, seeded prompt, fake-mode. |
| `HERMES_TUI_RPC_TIMEOUT_MS`, `HERMES_TUI_STARTUP_TIMEOUT_MS` | tests/CI | Protocol timeouts. |
| (`ui-tui` only) `HERMES_TUI_MEMSAMPLE_FD/MS` | bench | Ink fd-3 node sampler. |
## How the pieces compose (the support script)
- Regular user, normal day: zero flags, zero diagnostic commands visible.
- "My TUI feels heavy" support flow: `HERMES_TUI_DIAGNOSTICS=1 hermes``/mem`
for the live numbers, `/heapdump` for a snapshot to attach, window stats
exposed for `bench/live-attach.sh <pid>` to read.
- Developer profiling: same master switch + the individual knobs
(`HERMES_TUI_WINDOWING=0` A/B, `WINDOW_IDLE_MS` tuning) as needed.
- Anything in section 4 appearing in a user-facing doc is a bug.
Gating implementation: `logic/env.ts` (`diagnosticsEnabled()`),
`logic/slash.ts` (`DIAGNOSTIC_COMMANDS` — dispatch hint, help + completion
filtering), `view/transcript.tsx` (stats default). Tests:
`slash.test.ts` (gating both states), `utilityCommands.test.ts` (commands
themselves, gate enabled suite-wide).

View file

@ -15,6 +15,21 @@ export function envFlag(value: string | undefined, fallback: boolean): boolean {
return fallback
}
/**
* The diagnostics master switch `HERMES_TUI_DIAGNOSTICS` (default OFF).
*
* Gates the developer/profiling surface a regular user should never trip
* over: the diagnostic slash commands (`/mem`, `/heapdump`) and the default
* for `HERMES_TUI_WINDOW_STATS` (which can still be set individually). It is
* an enable switch, not a secret: anyone CAN set it (support flows say
* "relaunch with HERMES_TUI_DIAGNOSTICS=1"), it just keeps the day-to-day
* surface clean. Read per call so tests (and long-lived processes whose
* wrapper mutates env before launch) see the live value.
*/
export function diagnosticsEnabled(): boolean {
return envFlag(process.env.HERMES_TUI_DIAGNOSTICS, false)
}
/**
* Parse `HERMES_TUI_TOOL_OUTPUT_LINES` (a TUI-only env var deliberately NOT
* a config.yaml knob): how many output lines an expanded tool body shows.

View file

@ -11,6 +11,7 @@
* (exec/plugin system · alias re-dispatch · skill/send submit a turn ·
* prefill notice). Long output routes to the pager (Phase 5a).
*/
import { diagnosticsEnabled } from './env.ts'
import { DETAILS_SECTIONS, DETAILS_USAGE, type DetailsMode, nextDetailsMode, parseDetailsMode } from './details.ts'
import { formatBytes, memReport, performHeapdump } from './diagnostics.ts'
import { formatSpawnTree, formatSpawnTreeList, readSpawnTreeEntries } from './replay.ts'
@ -149,7 +150,12 @@ function present(ctx: SlashContext, title: string, text: string): void {
else ctx.pushSystem(text)
}
const CLIENT_HELP = [
/** Process-diagnostic commands hidden behind `HERMES_TUI_DIAGNOSTICS`
* (logic/env.ts). Regular users never see them; support flows enable them
* with one env var. Keep this set in sync with the `(diag)` lines below. */
const DIAGNOSTIC_COMMANDS = new Set(['mem', 'heapdump'])
const CLIENT_HELP_LINES = [
'/help — list commands',
'/model [name] — switch model (picker if bare)',
'/copy [n] — copy the last (or n-th) response',
@ -160,12 +166,17 @@ const CLIENT_HELP = [
'/compact [on|off|toggle] — compact transcript spacing',
'/details [hidden|collapsed|expanded|cycle] — tool/reasoning detail',
'/replay [n|path] — inspect an archived spawn tree',
'/mem — live memory stats',
'/heapdump — write a V8 heap snapshot',
'/mem — live memory stats (diag)',
'/heapdump — write a V8 heap snapshot (diag)',
'/logs — recent engine log lines',
'/quit, /exit — quit',
'(other /commands run on the gateway)'
].join('\n')
]
function clientHelp(): string {
const lines = diagnosticsEnabled() ? CLIENT_HELP_LINES : CLIENT_HELP_LINES.filter(l => !l.includes('(diag)'))
return lines.join('\n')
}
type ClientHandler = (arg: string, ctx: SlashContext) => void | Promise<void>
@ -631,9 +642,9 @@ const CLIENT: Record<string, ClientHandler> = {
// Prefer the live catalog; fall back to the client list if it's unavailable.
try {
const cat = await ctx.request('commands.catalog', {})
ctx.pushSystem(renderCatalog(cat) || CLIENT_HELP)
ctx.pushSystem(renderCatalog(cat) || clientHelp())
} catch {
ctx.pushSystem(CLIENT_HELP)
ctx.pushSystem(clientHelp())
}
},
logs: (_arg, ctx) => ctx.openPager('Logs', ctx.logTail().join('\n') || '(log empty)'),
@ -643,7 +654,8 @@ const CLIENT: Record<string, ClientHandler> = {
/** The registered client-command names (catalog introspection — tests/menus). */
export function clientCommandNames(): string[] {
return Object.keys(CLIENT).sort()
const names = Object.keys(CLIENT)
return (diagnosticsEnabled() ? names : names.filter(n => !DIAGNOSTIC_COMMANDS.has(n))).sort()
}
/** Render the gateway `commands.catalog` into a help block (loose-typed read).
@ -701,6 +713,12 @@ export async function dispatchSlash(input: string, ctx: SlashContext): Promise<v
const parsed = parseSlash(input)
if (!parsed) return
if (DIAGNOSTIC_COMMANDS.has(parsed.name) && !diagnosticsEnabled()) {
// Not a secret — an enable switch. Tell the user exactly how to get it.
ctx.pushSystem(`/${parsed.name} is a diagnostic command — relaunch with HERMES_TUI_DIAGNOSTICS=1 to enable it.`)
return
}
const client = CLIENT[parsed.name]
if (client) {
await client(parsed.arg, ctx)

View file

@ -7,6 +7,7 @@ import { afterEach, describe, expect, test } from 'vitest'
import type { DetailsMode } from '../logic/details.ts'
import {
buildModelTabs,
clientCommandNames,
dispatchSlash,
mapCompletions,
parseSlash,
@ -566,3 +567,40 @@ describe('dispatchSlash — server ladder', () => {
expect(p.system).toContain('done')
})
})
describe('diagnostic command gating (HERMES_TUI_DIAGNOSTICS)', () => {
const KEY = 'HERMES_TUI_DIAGNOSTICS'
const prev = process.env[KEY]
afterEach(() => {
if (prev === undefined) delete process.env[KEY]
else process.env[KEY] = prev
})
test('OFF (default): /mem and /heapdump respond with the enable hint, not the command', async () => {
delete process.env[KEY]
const p = makeCtx(async () => ({}))
await dispatchSlash('/mem', p.ctx)
await dispatchSlash('/heapdump', p.ctx)
expect(p.system[0]).toContain('HERMES_TUI_DIAGNOSTICS=1')
expect(p.system[1]).toContain('HERMES_TUI_DIAGNOSTICS=1')
expect(p.calls).toHaveLength(0) // never reached the gateway ladder either
})
test('OFF: the diagnostic names are absent from clientCommandNames()', () => {
delete process.env[KEY]
const names = clientCommandNames()
expect(names).not.toContain('mem')
expect(names).not.toContain('heapdump')
expect(names).toContain('logs') // non-diagnostic neighbors stay
})
test('ON: /mem executes (live memory stats), names are listed', async () => {
process.env[KEY] = '1'
expect(clientCommandNames()).toContain('mem')
expect(clientCommandNames()).toContain('heapdump')
const p = makeCtx(async () => ({}))
await dispatchSlash('/mem', p.ctx)
const out = [...p.system, ...p.paged.map(x => x.text)].join('\n')
expect(out).toMatch(/rss|heap/i)
})
})

View file

@ -22,6 +22,19 @@ import { formatSpawnTree, formatSpawnTreeList, readSpawnTreeEntries } from '../l
import { clientCommandNames, dispatchSlash, type SlashContext } from '../logic/slash.ts'
import type { Part } from '../logic/store.ts'
// The utility commands under test are DIAGNOSTIC commands — gated behind
// HERMES_TUI_DIAGNOSTICS (logic/env.ts). This suite tests the commands
// themselves, so enable the gate for the whole file (gating behavior has its
// own tests in slash.test.ts).
const PREV_DIAG = process.env.HERMES_TUI_DIAGNOSTICS
beforeEach(() => {
process.env.HERMES_TUI_DIAGNOSTICS = '1'
})
afterEach(() => {
if (PREV_DIAG === undefined) delete process.env.HERMES_TUI_DIAGNOSTICS
else process.env.HERMES_TUI_DIAGNOSTICS = PREV_DIAG
})
// /heapdump must not write a REAL multi-MB snapshot per test run — stub the V8
// seam; the path/mkdir plumbing still runs for real (under a temp HERMES_HOME).
vi.mock('node:v8', () => ({ writeHeapSnapshot: vi.fn((path?: string) => path ?? 'unnamed.heapsnapshot') }))

View file

@ -88,7 +88,7 @@ import type { BoxRenderable, ScrollBoxRenderable } from '@opentui/core'
import { useRenderer } from '@opentui/solid'
import { createComputed, createMemo, createSelector, createSignal, For, on, onCleanup, onMount, Show } from 'solid-js'
import { envFlag } from '../logic/env.ts'
import { diagnosticsEnabled, envFlag } from '../logic/env.ts'
import type { Message, SessionStore } from '../logic/store.ts'
import {
computeWindow,
@ -234,9 +234,10 @@ export function Transcript(props: { store: SessionStore }) {
if (!streaming) estimates.set(key, estimate)
return estimate
}
// DEV stats exposure for the bench (HERMES_TUI_WINDOW_STATS): the live
// DEV stats exposure for the bench (HERMES_TUI_WINDOW_STATS — defaults to
// the HERMES_TUI_DIAGNOSTICS master switch; settable individually): the live
// current/peak mounted-row counters from logic/window.ts.
if (envFlag(process.env.HERMES_TUI_WINDOW_STATS, false)) {
if (envFlag(process.env.HERMES_TUI_WINDOW_STATS, diagnosticsEnabled())) {
;(globalThis as unknown as Record<string, unknown>)['__hermesTuiWindowStats'] = windowRowStats()
}