From 58d75ec5c68ba9b4f4c5976ef96fb284d1f109e6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:36:01 -0500 Subject: [PATCH 01/22] feat(ui-tui): widget-grid 2-axis layout engine + overlay primitives (#20379 rebase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebase of the widget-grid PR onto current main, then grow it into a real 2-axis engine: resolveGridTracks (grid-template tracks — fixed cell counts and weighted fr shares with mins), layoutWidgetGrid (1D auto-packing flow), and layoutGridAreas (2D absolute placement with row/col spans and implicit row growth). Overlay/Dialog primitives give zoned viewport-level modals with an optional scrim. /grid-test (interactive: areas, nesting, zoom, gap/padding) and /grid-test streams (4x3 mission-control GridAreas board with promote-to-main) exercise everything end to end. --- ui-tui/node_modules | 1 + .../src/__tests__/createSlashHandler.test.ts | 16 + ui-tui/src/__tests__/widgetGrid.test.ts | 246 +++++++++ .../__tests__/widgetGridComponent.test.tsx | 143 +++++ ui-tui/src/app/interfaces.ts | 31 ++ ui-tui/src/app/overlayStore.ts | 8 + ui-tui/src/app/slash/commands/core.ts | 4 +- ui-tui/src/app/slash/commands/debug.ts | 106 ++++ ui-tui/src/app/useInputHandlers.ts | 192 ++++++- ui-tui/src/components/appLayout.tsx | 15 + ui-tui/src/components/appOverlays.tsx | 8 + ui-tui/src/components/gridStreamsDemo.tsx | 393 ++++++++++++++ ui-tui/src/components/gridTestOverlay.tsx | 316 +++++++++++ ui-tui/src/components/overlay.tsx | 141 +++++ ui-tui/src/components/skillsHub.tsx | 8 +- ui-tui/src/components/widgetGrid.tsx | 266 ++++++++++ ui-tui/src/lib/widgetGrid.ts | 495 ++++++++++++++++++ 17 files changed, 2380 insertions(+), 9 deletions(-) create mode 120000 ui-tui/node_modules create mode 100644 ui-tui/src/__tests__/widgetGrid.test.ts create mode 100644 ui-tui/src/__tests__/widgetGridComponent.test.tsx create mode 100644 ui-tui/src/components/gridStreamsDemo.tsx create mode 100644 ui-tui/src/components/gridTestOverlay.tsx create mode 100644 ui-tui/src/components/overlay.tsx create mode 100644 ui-tui/src/components/widgetGrid.tsx create mode 100644 ui-tui/src/lib/widgetGrid.ts diff --git a/ui-tui/node_modules b/ui-tui/node_modules new file mode 120000 index 000000000000..001f2a89107d --- /dev/null +++ b/ui-tui/node_modules @@ -0,0 +1 @@ +/Users/brooklyn/www/hermes-agent/ui-tui/node_modules \ No newline at end of file diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 7096798ecf93..59a9872ad756 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -70,6 +70,22 @@ describe('createSlashHandler', () => { expect(getOverlayState().sessions).toBe(true) }) + it('opens the grid-test overlay locally', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/grid-test 6x4')).toBe(true) + expect(getOverlayState().gridTest).toMatchObject({ cols: 6, nested: false, rows: 4, streams: false }) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + }) + + it('opens the grid-test streams demo via /grid-test streams', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/grid-test streams')).toBe(true) + expect(getOverlayState().gridTest).toMatchObject({ streamFocus: 0, streamMain: 0, streams: true }) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + }) + it('handles /redraw locally without slash worker fallback', () => { const ctx = buildCtx() diff --git a/ui-tui/src/__tests__/widgetGrid.test.ts b/ui-tui/src/__tests__/widgetGrid.test.ts new file mode 100644 index 000000000000..3fed524b6e38 --- /dev/null +++ b/ui-tui/src/__tests__/widgetGrid.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest' + +import { type GridTrackSize, layoutGridAreas, layoutWidgetGrid, resolveGridTracks } from '../lib/widgetGrid.js' + +describe('layoutWidgetGrid', () => { + it('falls back to a single column on narrow widths', () => { + const layout = layoutWidgetGrid({ + items: [{ id: 'a' }, { id: 'b' }], + maxColumns: 3, + minColumnWidth: 40, + width: 35 + }) + + expect(layout.columnCount).toBe(1) + expect(layout.columns).toEqual([35]) + expect(layout.rows).toEqual([[{ col: 0, id: 'a', span: 1, width: 35 }], [{ col: 0, id: 'b', span: 1, width: 35 }]]) + }) + + it('packs spans left-to-right and wraps to the next row', () => { + const layout = layoutWidgetGrid({ + gap: 2, + items: [ + { id: 'a', span: 1 }, + { id: 'b', span: 2 }, + { id: 'c', span: 1 } + ], + maxColumns: 3, + minColumnWidth: 30, + width: 100 + }) + + expect(layout.columnCount).toBe(3) + expect(layout.columns).toEqual([32, 32, 32]) + expect(layout.rows).toEqual([ + [ + { col: 0, id: 'a', span: 1, width: 32 }, + { col: 1, id: 'b', span: 2, width: 66 } + ], + [{ col: 0, id: 'c', span: 1, width: 32 }] + ]) + }) + + it('clamps spans to available columns', () => { + const layout = layoutWidgetGrid({ + gap: 1, + items: [{ id: 'huge', span: 9 }], + maxColumns: 2, + minColumnWidth: 20, + width: 50 + }) + + expect(layout.columnCount).toBe(2) + expect(layout.rows[0]?.[0]).toEqual({ + col: 0, + id: 'huge', + span: 2, + width: 50 + }) + }) + + it('honors an exact column count when the grid has room', () => { + const layout = layoutWidgetGrid({ + columns: 4, + gap: 1, + items: Array.from({ length: 8 }, (_, idx) => ({ id: `cell-${idx}` })), + width: 43 + }) + + expect(layout.columnCount).toBe(4) + expect(layout.columns).toEqual([10, 10, 10, 10]) + expect(layout.rows).toHaveLength(2) + }) + + it('renders sparse explicit starts without collapsing holes', () => { + const layout = layoutWidgetGrid({ + columns: 4, + gap: 1, + items: [ + { colStart: 0, id: 'a' }, + { colStart: 2, id: 'b' }, + { colStart: 3, id: 'c' } + ], + width: 43 + }) + + expect(layout.rows).toEqual([ + [ + { col: 0, id: 'a', span: 1, width: 10 }, + { col: 2, id: 'b', span: 1, width: 10 }, + { col: 3, id: 'c', span: 1, width: 10 } + ] + ]) + }) +}) + +describe('resolveGridTracks', () => { + it('splits equal fr tracks with the remainder spread left-to-right', () => { + expect(resolveGridTracks(10, 0, [{ fr: 1 }, { fr: 1 }, { fr: 1 }])).toEqual([4, 3, 3]) + }) + + it('gives fixed tracks their size and shares the leftover by weight', () => { + // usable = 40 - 2*1 = 38; fixed 10; leftover 28 over 1fr+2fr. + expect(resolveGridTracks(40, 1, [10, { fr: 1 }, { fr: 2 }])).toEqual([10, 10, 18]) + }) + + it('pins tracks that fall below their min and re-solves the rest', () => { + expect(resolveGridTracks(20, 0, [{ fr: 1 }, { fr: 1, min: 15 }])).toEqual([5, 15]) + }) + + it('never lets the sum exceed the usable axis when tracks fit at their minimums', () => { + const cases: Array<{ gap: number; total: number; tracks: GridTrackSize[] }> = [ + { gap: 1, total: 80, tracks: [12, { fr: 1 }, { fr: 3, min: 20 }, 6] }, + { gap: 2, total: 33, tracks: [{ fr: 1 }, { fr: 1 }, { fr: 1 }, { fr: 1 }] }, + { gap: 0, total: 9, tracks: [4, 4, { fr: 1 }] } + ] + + for (const { gap, total, tracks } of cases) { + const sizes = resolveGridTracks(total, gap, tracks) + const usable = total - gap * (tracks.length - 1) + + expect(sizes.reduce((acc, s) => acc + s, 0)).toBeLessThanOrEqual(Math.max(tracks.length, usable)) + expect(sizes.every(s => s >= 1)).toBe(true) + } + }) + + it('shaves overflow off trailing tracks when fixed sizes exceed the axis', () => { + const sizes = resolveGridTracks(20, 0, [15, 15, { fr: 1 }]) + + expect(sizes.reduce((acc, s) => acc + s, 0)).toBeLessThanOrEqual(20) + expect(sizes[0]).toBe(15) + expect(sizes.every(s => s >= 1)).toBe(true) + }) +}) + +describe('layoutGridAreas', () => { + it('solves a rowSpan cell so it occupies both rows and blocks placement beneath it', () => { + const layout = layoutGridAreas({ + columns: 2, + gap: 0, + height: 10, + items: [{ id: 'tall', rowSpan: 2 }, { id: 'b' }, { id: 'c' }], + rowGap: 0, + width: 20 + }) + + const [tall, b, c] = layout.cells + + expect(tall).toMatchObject({ col: 0, height: 10, row: 0, rowSpan: 2, width: 10, x: 0, y: 0 }) + expect(b).toMatchObject({ col: 1, row: 0, x: 10, y: 0 }) + // c cannot go under `tall` — it lands in the second row's free column. + expect(c).toMatchObject({ col: 1, row: 1, x: 10, y: 5 }) + }) + + it('fills holes densely (auto-flow dense) after a colSpan pushes a wrap', () => { + const layout = layoutGridAreas({ + columns: 3, + gap: 0, + height: 6, + items: [{ id: 'a' }, { colSpan: 2, id: 'wide' }, { id: 'filler' }], + width: 30 + }) + + const byId = Object.fromEntries(layout.cells.map(cell => [cell.id, cell])) + + expect(byId['a']).toMatchObject({ col: 0, row: 0 }) + expect(byId['wide']).toMatchObject({ col: 1, colSpan: 2, row: 0 }) + expect(byId['filler']).toMatchObject({ col: 0, row: 1 }) + }) + + it('spans include the gaps they bridge', () => { + const layout = layoutGridAreas({ + columns: [{ fr: 1 }, { fr: 1 }, { fr: 1 }], + gap: 2, + height: 11, + items: [{ colSpan: 3, id: 'full' }, { id: 'a' }, { id: 'b' }, { rowSpan: 2, id: 'tall' }], + rowGap: 1, + width: 32 + }) + + const byId = Object.fromEntries(layout.cells.map(cell => [cell.id, cell])) + + // 3 tracks over usable 28 → [10, 9, 9]; full spans all three + two gaps. + expect(layout.columnSizes).toEqual([10, 9, 9]) + expect(byId['full']!.width).toBe(32) + // 3 rows over usable 9 → [3, 3, 3]; tall spans rows 2-3 + one rowGap. + expect(layout.rowSizes).toEqual([3, 3, 3]) + expect(byId['tall']!.height).toBe(7) + expect(byId['tall']!.y).toBe(4) + }) + + it('honors explicit col/row pins and grows implicit rows beneath explicit tracks', () => { + const layout = layoutGridAreas({ + columns: 2, + gap: 0, + height: 12, + items: [ + { col: 1, id: 'pinned', row: 2 }, + { id: 'auto-a' }, + { id: 'auto-b' } + ], + rowGap: 0, + rows: 2, + width: 10 + }) + + const byId = Object.fromEntries(layout.cells.map(cell => [cell.id, cell])) + + // The pin forces a third row beyond the two explicit tracks. + expect(layout.rowCount).toBe(3) + expect(byId['pinned']).toMatchObject({ col: 1, row: 2 }) + expect(byId['auto-a']).toMatchObject({ col: 0, row: 0 }) + expect(byId['auto-b']).toMatchObject({ col: 1, row: 0 }) + }) + + it('supports weighted and fixed row tracks', () => { + const layout = layoutGridAreas({ + columns: 1, + gap: 0, + height: 20, + items: [{ id: 'header' }, { id: 'body' }, { id: 'footer' }], + rowGap: 0, + rows: [3, { fr: 1 }, 3], + width: 40 + }) + + expect(layout.rowSizes).toEqual([3, 14, 3]) + + const byId = Object.fromEntries(layout.cells.map(cell => [cell.id, cell])) + + expect(byId['header']!.height).toBe(3) + expect(byId['body']).toMatchObject({ height: 14, y: 3 }) + expect(byId['footer']).toMatchObject({ height: 3, y: 17 }) + }) + + it('clamps colSpan to the column count', () => { + const layout = layoutGridAreas({ + columns: 2, + gap: 0, + height: 4, + items: [{ colSpan: 9, id: 'huge' }], + width: 10 + }) + + expect(layout.cells[0]).toMatchObject({ col: 0, colSpan: 2, width: 10 }) + }) +}) diff --git a/ui-tui/src/__tests__/widgetGridComponent.test.tsx b/ui-tui/src/__tests__/widgetGridComponent.test.tsx new file mode 100644 index 000000000000..1ae15101881b --- /dev/null +++ b/ui-tui/src/__tests__/widgetGridComponent.test.tsx @@ -0,0 +1,143 @@ +import { PassThrough } from 'stream' + +import { renderSync, Text } from '@hermes/ink' +import React, { useState } from 'react' +import { describe, expect, it } from 'vitest' + +import { GRID_STREAM_COUNT, type GridTestState } from '../app/interfaces.js' +import { GridStreamsDemo, STREAM_DEFS } from '../components/gridStreamsDemo.js' +import { GridAreas, type GridAreaWidget, WidgetGrid, type WidgetGridWidget } from '../components/widgetGrid.js' +import { stripAnsi } from '../lib/text.js' +import { DEFAULT_THEME } from '../theme.js' + +function StatefulCell({ label }: { label: string }) { + const [value] = useState(label) + + return {value} +} + +const renderToText = (node: React.ReactElement) => { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + let output = '' + + Object.assign(stdout, { columns: 100, isTTY: false, rows: 24 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const instance = renderSync(node, { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + }) + + instance.unmount() + instance.cleanup() + + return stripAnsi(output) +} + +const renderGrid = (widgets: WidgetGridWidget[]) => + renderToText() + +describe('WidgetGrid component composition', () => { + it('renders stateful direct children and nested grids inside cells', () => { + const output = renderGrid([ + { + children: , + id: 'stateful' + }, + { + children: ( + , id: 'nested-c1' }, + { render: () => , id: 'nested-c2' } + ]} + /> + ), + id: 'nested-grid' + } + ]) + + expect(output).toContain('stateful-c1') + expect(output).toContain('nested-c1') + expect(output).toContain('nested-c2') + }) +}) + +describe('GridAreas component', () => { + it('renders a rowSpan cell alongside stacked cells at their solved rects', () => { + const widgets: GridAreaWidget[] = [ + { id: 'tall', render: cell => {`tall ${cell.width}x${cell.height}`}, rowSpan: 2 }, + { children: top-right, id: 'b' }, + { children: cell => {`bottom-right y${cell.y}`}, id: 'c' } + ] + + const output = renderToText() + + // tall spans both rows of the 6-row grid at 20 cells wide. + expect(output).toContain('tall 20x6') + expect(output).toContain('top-right') + expect(output).toContain('bottom-right y3') + }) + + it('gives fixed header/footer rows their size and the fr body the rest', () => { + const widgets: GridAreaWidget[] = [ + { children: cell => {`header h${cell.height}`}, id: 'header' }, + { children: cell => {`body h${cell.height}`}, id: 'body' }, + { children: cell => {`footer h${cell.height}`}, id: 'footer' } + ] + + const output = renderToText( + + ) + + expect(output).toContain('header h1') + expect(output).toContain('body h10') + expect(output).toContain('footer h1') + }) +}) + +describe('GridStreamsDemo', () => { + const streamsState: GridTestState = { + activeCol: 0, + activeRow: 0, + areas: false, + cols: 4, + gap: null, + nested: false, + paddingX: null, + rows: 3, + streamFocus: 1, + streamMain: 2, + streams: true, + zoomed: false + } + + it('keeps the panel count in lockstep with the input handler focus wrap', () => { + expect(STREAM_DEFS.length).toBe(GRID_STREAM_COUNT) + }) + + it('renders every stream panel with the promoted panel in the header', () => { + const output = renderToText() + + expect(output).toContain('hermes mission control') + + for (const def of STREAM_DEFS) { + expect(output).toContain(def.title) + } + + // streamMain: 2 → the memory panel owns the promoted slot. + expect(output).toContain('main: memory') + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 33f2faec24b5..f9959eba00ce 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -283,6 +283,8 @@ export interface OverlayState { billing: BillingOverlayState | null clarify: ClarifyReq | null confirm: ConfirmReq | null + dialog: DialogState | null + gridTest: GridTestState | null journey: boolean modelPicker: boolean | { refresh?: boolean } pager: null | PagerState @@ -295,12 +297,41 @@ export interface OverlayState { sudo: null | SudoReq } +export interface DialogState { + body: string + hint?: string + title?: string + zone?: 'bottom' | 'bottom-left' | 'bottom-right' | 'center' | 'left' | 'right' | 'top' | 'top-left' | 'top-right' +} + export interface PagerState { lines: string[] offset: number title?: string } +/** Number of live panels in the /grid-test streams demo (focus wraps mod this). */ +export const GRID_STREAM_COUNT = 6 + +export interface GridTestState { + activeCol: number + activeRow: number + /** Areas mode: fixed-height 2D grid with rowSpan/colSpan demo cells. */ + areas: boolean + cols: number + gap: null | number + nested: boolean + paddingX: null | number + rows: number + /** Streams mode: live-updating panels tiled by GridAreas. */ + streams: boolean + /** Streams mode: which panel h/l focus is on (0-based, wraps). */ + streamFocus: number + /** Streams mode: which panel owns the promoted 2x2 slot. */ + streamMain: number + zoomed: boolean +} + export interface TranscriptRow { index: number key: string diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index bff021f31530..6eaaf581dc0c 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -9,6 +9,8 @@ const buildOverlayState = (): OverlayState => ({ billing: null, clarify: null, confirm: null, + dialog: null, + gridTest: null, journey: false, modelPicker: false, pager: null, @@ -31,6 +33,8 @@ export const $isBlocked = computed( billing, clarify, confirm, + dialog, + gridTest, journey, modelPicker, pager, @@ -48,6 +52,8 @@ export const $isBlocked = computed( billing || clarify || confirm || + dialog || + gridTest || journey || modelPicker || pager || @@ -82,6 +88,8 @@ export const resetFlowOverlays = () => ...buildOverlayState(), agents: $overlayState.get().agents, agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex, + dialog: $overlayState.get().dialog, + gridTest: $overlayState.get().gridTest, journey: $overlayState.get().journey, modelPicker: $overlayState.get().modelPicker, petPicker: $overlayState.get().petPicker, diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 3a952c399d5e..25dfa54b2a36 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -107,7 +107,9 @@ export const coreCommands: SlashCommand[] = [ '/details
[hidden|collapsed|expanded|reset]', 'override one section (thinking/tools/subagents/activity)' ], - ['/fortune [random|daily]', 'show a random or daily local fortune'] + ['/fortune [random|daily]', 'show a random or daily local fortune'], + ['/grid-test [cols]x[rows]', 'open the interactive widget-grid demo'], + ['/dialog-test [zone]', 'open a sample dialog overlay with a faked backdrop'] ], title: 'TUI' }, diff --git a/ui-tui/src/app/slash/commands/debug.ts b/ui-tui/src/app/slash/commands/debug.ts index b4bfc16bfbc9..868414a66d04 100644 --- a/ui-tui/src/app/slash/commands/debug.ts +++ b/ui-tui/src/app/slash/commands/debug.ts @@ -1,7 +1,113 @@ import { formatBytes, performHeapDump } from '../../../lib/memory.js' +import type { DialogState } from '../../interfaces.js' +import { patchOverlayState } from '../../overlayStore.js' import type { SlashCommand } from '../types.js' +const GRID_TEST_USAGE = 'usage: /grid-test [cols]x[rows] · /grid-test [cols] [rows] · /grid-test streams' +const GRID_TEST_MAX_SIZE = 12 + +const DIALOG_TEST_ZONES = new Set([ + 'bottom', + 'bottom-left', + 'bottom-right', + 'center', + 'left', + 'right', + 'top', + 'top-left', + 'top-right' +]) + +const DIALOG_TEST_USAGE = `usage: /dialog-test [zone] zones: ${[...DIALOG_TEST_ZONES].join(', ')}` + +const clampGridSize = (value: number, fallback: number) => { + if (!Number.isFinite(value)) { + return fallback + } + + return Math.max(1, Math.min(GRID_TEST_MAX_SIZE, Math.trunc(value))) +} + +const parseGridTestSize = (arg: string) => { + const trimmed = arg.trim() + + if (!trimmed) { + return { cols: 4, rows: 3 } + } + + const grid = trimmed.match(/^(\d+)\s*x\s*(\d+)$/i) + + if (grid) { + return { cols: clampGridSize(Number(grid[1]), 4), rows: clampGridSize(Number(grid[2]), 3) } + } + + const [cols, rows, ...rest] = trimmed.split(/\s+/) + + if (rest.length || !cols || !rows || Number.isNaN(Number(cols)) || Number.isNaN(Number(rows))) { + return null + } + + return { cols: clampGridSize(Number(cols), 4), rows: clampGridSize(Number(rows), 3) } +} + export const debugCommands: SlashCommand[] = [ + { + help: 'open an interactive widget-grid demo overlay', + name: 'grid-test', + run: (arg, ctx) => { + const streams = arg.trim().toLowerCase() === 'streams' + const size = streams ? { cols: 4, rows: 3 } : parseGridTestSize(arg) + + if (!size) { + return ctx.transcript.sys(GRID_TEST_USAGE) + } + + patchOverlayState({ + gridTest: { + activeCol: 0, + activeRow: 0, + areas: false, + cols: size.cols, + gap: null, + nested: false, + paddingX: null, + rows: size.rows, + streamFocus: 0, + streamMain: 0, + streams, + zoomed: false + } + }) + } + }, + + { + help: 'open a sample dialog overlay with a faked backdrop', + name: 'dialog-test', + run: (arg, ctx) => { + const trimmed = arg.trim().toLowerCase() + const zone = (trimmed || 'center') as DialogState['zone'] + + if (!DIALOG_TEST_ZONES.has(zone)) { + return ctx.transcript.sys(DIALOG_TEST_USAGE) + } + + patchOverlayState({ + dialog: { + body: [ + 'This is a viewport-level overlay with a backdrop.', + '', + `Zone: ${zone}`, + 'Try: /dialog-test top-right · bottom · left · ...' + ].join('\n'), + hint: 'Esc/q/Enter close · Ctrl+C close', + title: 'Dialog primitive', + zone + } + }) + } + }, + { help: 'write a V8 heap snapshot + memory diagnostics (see HERMES_HEAPDUMP_DIR)', name: 'heapdump', diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 2f2cdff3c718..8bd5f2944a01 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -16,12 +16,14 @@ import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionW import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js' import { getInputSelection } from './inputSelectionStore.js' -import type { - GatewayRpc, - InputHandlerActions, - InputHandlerContext, - InputHandlerResult, - OverlayState +import { + type GatewayRpc, + GRID_STREAM_COUNT, + type GridTestState, + type InputHandlerActions, + type InputHandlerContext, + type InputHandlerResult, + type OverlayState } from './interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' import { turnController } from './turnController.js' @@ -127,6 +129,23 @@ export function dismissSensitivePrompt( } } +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)) +const GRID_TEST_MAX_SIZE = 12 + +const cycleAutoNumber = (value: null | number, max: number) => { + if (value === null) { + return 0 + } + + return value >= max ? null : value + 1 +} + +const keepGridCursorInBounds = (grid: GridTestState): GridTestState => ({ + ...grid, + activeCol: clamp(grid.activeCol, 0, grid.cols - 1), + activeRow: clamp(grid.activeRow, 0, grid.rows - 1) +}) + export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const { actions, composer, gateway, terminal, voice, wheelStep } = ctx const { actions: cActions, refs: cRefs, state: cState } = composer @@ -218,6 +237,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { if (overlay.journey) { return patchOverlayState({ journey: false }) } + + if (overlay.gridTest) { + return patchOverlayState({ gridTest: null }) + } + + if (overlay.dialog) { + return patchOverlayState({ dialog: null }) + } } const cycleQueue = (dir: 1 | -1) => { @@ -401,6 +428,159 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return } + if (overlay.gridTest) { + const updateGrid = (fn: (grid: GridTestState) => GridTestState) => + patchOverlayState(prev => + prev.gridTest ? { ...prev, gridTest: keepGridCursorInBounds(fn(prev.gridTest)) } : prev + ) + + const openDemoDialog = () => + patchOverlayState({ + dialog: { + body: ['Dialog overlaid on top of /grid-test.', '', 'Backdrop dims the grid behind.'].join('\n'), + hint: 'Esc/q/Enter close', + title: 'Overlay primitive', + zone: 'center' + } + }) + + const resetGrid = () => + updateGrid(grid => ({ + ...grid, + activeCol: 0, + activeRow: 0, + areas: false, + cols: 4, + gap: null, + nested: false, + paddingX: null, + rows: 3, + streamFocus: 0, + streamMain: 0, + streams: false, + zoomed: false + })) + + if (isCtrl(key, ch, 'c')) { + return patchOverlayState({ gridTest: null }) + } + + // Streams mode swallows the grid-shape keys: focus cycles across the + // live panels and Enter promotes the focused one to the 2x2 slot. + if (overlay.gridTest.streams) { + if (key.escape || ch === 'q' || ch === 's') { + return updateGrid(grid => ({ ...grid, streams: false })) + } + + if (key.return) { + return updateGrid(grid => ({ ...grid, streamMain: grid.streamFocus })) + } + + if (ch === 'd') { + return openDemoDialog() + } + + if (ch === 'r') { + return resetGrid() + } + + if (key.leftArrow || key.upArrow || ch === 'h' || ch === 'k') { + return updateGrid(grid => ({ + ...grid, + streamFocus: (grid.streamFocus + GRID_STREAM_COUNT - 1) % GRID_STREAM_COUNT + })) + } + + if (key.rightArrow || key.downArrow || ch === 'l' || ch === 'j') { + return updateGrid(grid => ({ ...grid, streamFocus: (grid.streamFocus + 1) % GRID_STREAM_COUNT })) + } + + return + } + + if (overlay.gridTest.zoomed && (key.escape || ch === 'q')) { + return updateGrid(grid => ({ ...grid, zoomed: false })) + } + + if (key.escape || ch === 'q') { + return patchOverlayState({ gridTest: null }) + } + + if (key.return) { + return updateGrid(grid => ({ ...grid, nested: true, zoomed: true })) + } + + if (ch === 'n') { + return updateGrid(grid => ({ ...grid, nested: !grid.nested })) + } + + if (ch === 'a') { + return updateGrid(grid => ({ ...grid, areas: !grid.areas, streams: false })) + } + + if (ch === 's') { + return updateGrid(grid => ({ ...grid, areas: false, streams: true })) + } + + if (ch === 'g') { + return updateGrid(grid => ({ ...grid, gap: cycleAutoNumber(grid.gap, 3) })) + } + + if (ch === 'p') { + return updateGrid(grid => ({ ...grid, paddingX: cycleAutoNumber(grid.paddingX, 2) })) + } + + if (ch === 'd') { + return openDemoDialog() + } + + if (ch === 'r') { + return resetGrid() + } + + if (ch === '+' || ch === '=') { + return updateGrid(grid => ({ ...grid, cols: clamp(grid.cols + 1, 1, GRID_TEST_MAX_SIZE) })) + } + + if (ch === '-' || ch === '_') { + return updateGrid(grid => ({ ...grid, cols: clamp(grid.cols - 1, 1, GRID_TEST_MAX_SIZE) })) + } + + if (ch === ']') { + return updateGrid(grid => ({ ...grid, rows: clamp(grid.rows + 1, 1, GRID_TEST_MAX_SIZE) })) + } + + if (ch === '[') { + return updateGrid(grid => ({ ...grid, rows: clamp(grid.rows - 1, 1, GRID_TEST_MAX_SIZE) })) + } + + if (key.leftArrow || ch === 'h') { + return updateGrid(grid => ({ ...grid, activeCol: clamp(grid.activeCol - 1, 0, grid.cols - 1) })) + } + + if (key.rightArrow || ch === 'l') { + return updateGrid(grid => ({ ...grid, activeCol: clamp(grid.activeCol + 1, 0, grid.cols - 1) })) + } + + if (key.upArrow || ch === 'k') { + return updateGrid(grid => ({ ...grid, activeRow: clamp(grid.activeRow - 1, 0, grid.rows - 1) })) + } + + if (key.downArrow || ch === 'j') { + return updateGrid(grid => ({ ...grid, activeRow: clamp(grid.activeRow + 1, 0, grid.rows - 1) })) + } + + return + } + + if (overlay.dialog) { + if (key.escape || isCtrl(key, ch, 'c') || ch === 'q' || key.return) { + return patchOverlayState({ dialog: null }) + } + + return + } + if (isCtrl(key, ch, 'c') || (key.escape && (overlay.secret || overlay.sudo))) { cancelOverlayFromCtrlC() } else if (key.escape && overlay.sessions) { diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index d641e39d0d4a..3810d5a0da1c 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -28,6 +28,7 @@ import { FpsOverlay } from './fpsOverlay.js' import { HelpHint } from './helpHint.js' import { Journey } from './journey.js' import { MessageLine } from './messageLine.js' +import { Dialog, Overlay } from './overlay.js' import { PetKitty, PetSprite } from './petSprite.js' import { QueuedMessages } from './queuedMessages.js' import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js' @@ -560,6 +561,20 @@ export const AppLayout = memo(function AppLayout({ {!overlay.agents && } + + {overlay.dialog && ( + + + {overlay.dialog.body.split('\n').map((line, i) => ( + {line || ' '} + ))} + + + )} ) }) diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 80e040e4fe9a..5075598db618 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -9,6 +9,7 @@ import { $uiSessionId, $uiTheme } from '../app/uiStore.js' import { ActiveSessionSwitcher } from './activeSessionSwitcher.js' import { FloatBox } from './appChrome.js' import { BillingOverlay } from './billingOverlay.js' +import { GridTestOverlay } from './gridTestOverlay.js' import { MaskedPrompt } from './maskedPrompt.js' import { ModelPicker } from './modelPicker.js' import { OverlayHint } from './overlayControls.js' @@ -157,6 +158,7 @@ export function FloatingOverlays({ const theme = useStore($uiTheme) const hasAny = + overlay.gridTest || overlay.modelPicker || overlay.pager || overlay.petPicker || @@ -178,6 +180,12 @@ export function FloatingOverlays({ return ( + {overlay.gridTest && ( + + + + )} + {overlay.sessions && ( ReactNode + title: string +} + +// ── tick + history hooks ──────────────────────────────────────────────────── + +const useTick = (ms: number) => { + const [tick, setTick] = useState(0) + + useEffect(() => { + const timer = setInterval(() => setTick(v => v + 1), ms) + + return () => clearInterval(timer) + }, [ms]) + + return tick +} + +/** Ring-buffered sample history: one `sample()` per tick, capped at `cap`. */ +const useHistory = (tick: number, sample: () => number, cap = 240) => { + const historyRef = useRef([]) + + useEffect(() => { + historyRef.current.push(sample()) + + if (historyRef.current.length > cap) { + historyRef.current.splice(0, historyRef.current.length - cap) + } + // The sampler is intentionally re-run per tick, not per identity. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tick]) + + return historyRef.current +} + +/** + * Render history as a column chart `rows` lines tall (top line first). Each + * column gets `rows * 8` vertical levels — full blocks below the value, a + * partial eighth-block at the value row, spaces above — so a promoted (taller) + * cell genuinely gains chart resolution rather than just whitespace. + */ +const sparkRows = (history: number[], width: number, rows: number): string[] => { + const window = history.slice(-Math.max(1, width)) + + if (!window.length) { + return Array.from({ length: rows }, () => '') + } + + const min = Math.min(...window) + const max = Math.max(...window) + const range = max - min || 1 + const levels = window.map(v => Math.max(1, Math.round(((v - min) / range) * rows * 8))) + + return Array.from({ length: rows }, (_, lineIdx) => { + const rowFromBottom = rows - 1 - lineIdx + + return levels + .map(level => { + const filled = Math.min(8, Math.max(0, level - rowFromBottom * 8)) + + return filled === 0 ? ' ' : SPARK_CHARS[filled - 1] + }) + .join('') + }) +} + +// ── stream panels ─────────────────────────────────────────────────────────── + +const TOKEN_WORDS = + (`Hermes streams tokens into the promoted cell while the grid reshapes around it. ` + + `Cells are keyed by id, so promotion never resets a panel — history, cursors and ` + + `tickers all survive the relayout. Row and column tracks re-solve to integer ` + + `terminal cells on every change, spans bridge the gaps they cross, and dense ` + + `auto-placement backfills the holes the promoted panel leaves behind. `).split(' ') + +function TokenStream({ height, t, width }: { height: number; t: Theme; width: number }) { + const tick = useTick(90) + const wordCount = tick % (TOKEN_WORDS.length * 4) + // Keep roughly enough trailing text to fill the cell, on word boundaries. + const budget = Math.max(16, width * height) + const words: string[] = [] + let used = 0 + + for (let i = wordCount; i >= 0 && used < budget; i--) { + const word = TOKEN_WORDS[i % TOKEN_WORDS.length]! + + words.unshift(word) + used += word.length + 1 + } + + return ( + + {words.join(' ')} + + + ) +} + +function SparkStream({ + color, + format, + height, + interval, + sample, + t, + width +}: { + color: string + format: (value: number) => string + height: number + interval: number + sample: () => number + t: Theme + width: number +}) { + const tick = useTick(interval) + const history = useHistory(tick, sample) + const current = history[history.length - 1] ?? 0 + // Chart height grows with the cell — a promoted cell gets a taller chart. + const rows = Math.max(1, height - 1) + + return ( + + + {format(current)} + + + {sparkRows(history, width, rows).map((line, idx) => ( + + {line} + + ))} + + ) +} + +const FAKE_TOOLS = [ + '┊ read_file src/app/chat.tsx', + '┊ terminal npm run typecheck', + '┊ search_files "GridAreas"', + '┊ patch ui-tui/src/lib/widgetGrid.ts', + '┊ web_search yoga absolute layout', + '┊ delegate_task refactor sparkline', + '┊ terminal scripts/run_tests.sh', + '┊ vision_analyze screenshot.png' +] + +function ToolTicker({ height, t }: { height: number; t: Theme; width: number }) { + const tick = useTick(600) + const visible = Math.max(1, height) + + const lines = Array.from({ length: Math.min(visible, tick + 1) }, (_, idx) => { + const line = FAKE_TOOLS[(tick - idx) % FAKE_TOOLS.length]! + + return { line, recent: idx === 0 } + }).reverse() + + return ( + + {lines.map(({ line, recent }, idx) => ( + + {line} + + ))} + + ) +} + +function MetaPanel({ t }: { height: number; t: Theme; width: number }) { + const tick = useTick(1000) + const startedRef = useRef(Date.now()) + const uptime = Math.floor((Date.now() - startedRef.current) / 1000) + + return ( + + {new Date().toLocaleTimeString()} + {`up ${Math.floor(uptime / 60)}m ${uptime % 60}s`} + {`${tick} ticks`} + + ) +} + +// ── stream registry ───────────────────────────────────────────────────────── + +const sineSampler = () => (Math.sin(Date.now() / 700) + 1) / 2 + Math.random() * 0.15 + +let walkValue = 0.5 + +const walkSampler = () => { + walkValue = Math.min(1, Math.max(0, walkValue + (Math.random() - 0.5) * 0.2)) + + return walkValue +} + +/** Exported so tests can assert the count matches GRID_STREAM_COUNT (focus wraps mod it). */ +export const STREAM_DEFS: StreamDef[] = [ + { + id: 'tokens', + render: ({ height, t, width }) => , + title: 'token stream' + }, + { + id: 'throughput', + render: ({ height, t, width }) => ( + `${Math.round(20 + v * 40)} tok/s`} + height={height} + interval={150} + sample={sineSampler} + t={t} + width={width} + /> + ), + title: 'throughput' + }, + { + id: 'heap', + render: ({ height, t, width }) => ( + `heap ${(v / 1024 / 1024).toFixed(1)} MB`} + height={height} + interval={500} + sample={() => process.memoryUsage().heapUsed} + t={t} + width={width} + /> + ), + title: 'memory' + }, + { + id: 'latency', + render: ({ height, t, width }) => ( + `${Math.round(20 + v * 180)} ms`} + height={height} + interval={250} + sample={walkSampler} + t={t} + width={width} + /> + ), + title: 'latency' + }, + { + id: 'tools', + render: ({ height, t, width }) => , + title: 'tool feed' + }, + { + id: 'meta', + render: ({ height, t, width }) => , + title: 'session' + } +] + +// ── the demo surface ──────────────────────────────────────────────────────── + +function StreamPanel({ + cell, + focused, + main, + t, + title, + children +}: { + cell: GridAreaCell + children: (inner: { height: number; t: Theme; width: number }) => ReactNode + focused: boolean + main: boolean + t: Theme + title: string +}) { + const innerWidth = Math.max(1, cell.width - 4) + const innerHeight = Math.max(1, cell.height - 3) + const borderColor = focused ? t.color.primary : main ? t.color.accent : t.color.border + + return ( + + + {focused ? '▸ ' : ' '} + {title} + {main ? ' ·' : ''} + + + + {children({ height: innerHeight, t, width: innerWidth })} + + + ) +} + +export const GridStreamsDemo = memo(function GridStreamsDemo({ + cols, + state, + t +}: { + cols: number + state: GridTestState + t: Theme +}) { + const columnTracks: GridTrackSize[] = [{ fr: 1 }, { fr: 1 }, { fr: 1 }] + const main = STREAM_DEFS[state.streamMain % STREAM_DEFS.length]! + + // Promoted panel first so dense placement gives it the top-left 2x2; the + // rest backfill in definition order. Ids never change, so React reconciles + // each panel across promotions and its ticking state survives. + const ordered = [main, ...STREAM_DEFS.filter(def => def.id !== main.id)] + + const widgets: GridAreaWidget[] = [ + { + colSpan: 3, + id: 'stream-header', + render: cell => ( + + + hermes mission control + + {`main: ${main.title}`} + + ) + }, + ...ordered.map((def, idx) => ({ + colSpan: idx === 0 ? 2 : 1, + id: `stream-${def.id}`, + render: (cell: GridAreaCell) => ( + + {def.render} + + ), + rowSpan: idx === 0 ? 2 : 1 + })) + ] + + return ( + + ) +}) diff --git a/ui-tui/src/components/gridTestOverlay.tsx b/ui-tui/src/components/gridTestOverlay.tsx new file mode 100644 index 000000000000..b53ed031f679 --- /dev/null +++ b/ui-tui/src/components/gridTestOverlay.tsx @@ -0,0 +1,316 @@ +import { Box, Text } from '@hermes/ink' + +import type { GridTestState } from '../app/interfaces.js' +import type { GridAreaCell, GridTrackSize } from '../lib/widgetGrid.js' +import type { Theme } from '../theme.js' + +import { GridStreamsDemo } from './gridStreamsDemo.js' +import { GridAreas, type GridAreaWidget, WidgetGrid, type WidgetGridWidget } from './widgetGrid.js' + +interface GridTestOverlayProps { + cols: number + state: GridTestState + t: Theme +} + +const NESTED_GAP = 1 +// Heights are odd so a single line is true-centered: border, blank, text, blank, border. +const FLAT_CELL_HEIGHT = 5 +const NESTED_CELL_HEIGHT = 9 +const MINI_CELL_HEIGHT = 3 + +// Sparse "every-other" pattern so the nested toggle visibly differs from the active cell. +const showsNestedPreview = (row: number, col: number) => row % 2 === 0 && col % 2 === 0 + +export function GridTestOverlay({ cols, state, t }: GridTestOverlayProps) { + const gridCols = Math.max(12, cols) + const activeIdx = state.activeRow * state.cols + state.activeCol + const activeLabel = `c${activeIdx + 1}` + + const widgets: WidgetGridWidget[] = Array.from({ length: state.rows * state.cols }, (_, idx) => { + const row = Math.floor(idx / state.cols) + const col = idx % state.cols + const active = idx === activeIdx + const label = `c${idx + 1}` + + return { + id: `cell-${idx}`, + render: width => ( + + ) + } + }) + + return ( + + + + {state.zoomed ? `/grid-test / r${state.activeRow + 1} c${state.activeCol + 1}` : '/grid-test'} + + {state.streams ? 'streams' : `${state.cols}x${state.rows} grid`} + + + + {state.zoomed + ? 'arrows/hjkl switch cell · Esc/q back · Ctrl+C close' + : state.streams + ? 'arrows/hjkl focus · Enter promote · d dialog · Esc/q back · Ctrl+C close' + : 'arrows/hjkl move · Enter zoom · d dialog · a areas · s streams · +/- cols · [] rows · g gap · p pad · n nest · q close'} + + + + {state.zoomed ? ( + + ) : state.streams ? ( + + ) : state.areas ? ( + + ) : ( + + )} + + + {!state.zoomed && !state.streams && ( + + + gap {state.gap ?? 'auto'} · pad {state.paddingX ?? 'auto'} · nested {state.nested ? 'on' : 'off'} · areas{' '} + {state.areas ? 'on (2fr first col · c1 spans rows · c2 spans cols)' : 'off'} + + + )} + + ) +} + +/** + * Areas-mode demo: the same cols×rows surface driven by `GridAreas` — a + * weighted first column (2fr vs 1fr when there's room), `c1` spanning two + * rows, `c2` spanning two columns, everything else auto-placed dense. The + * cursor highlights whichever cell's row/col range contains it, so moving + * onto a spanned cell lights the whole merged area. + */ +function AreasDemo({ cols, state, t }: { cols: number; state: GridTestState; t: Theme }) { + const height = state.rows * FLAT_CELL_HEIGHT + + const columnTracks: GridTrackSize[] = + state.cols >= 3 + ? [{ fr: 2 }, ...Array.from({ length: state.cols - 1 }, () => ({ fr: 1 }))] + : Array.from({ length: state.cols }, () => ({ fr: 1 })) + + const rowSpanFirst = state.rows >= 2 ? 2 : 1 + const colSpanSecond = state.cols >= 2 ? 2 : 1 + const extraSlots = (rowSpanFirst - 1) + (colSpanSecond - 1) + const itemCount = Math.max(1, state.rows * state.cols - extraSlots) + + const cursorInside = (cell: GridAreaCell) => + state.activeRow >= cell.row && + state.activeRow < cell.row + cell.rowSpan && + state.activeCol >= cell.col && + state.activeCol < cell.col + cell.colSpan + + const widgets: GridAreaWidget[] = Array.from({ length: itemCount }, (_, idx) => ({ + colSpan: idx === 1 ? colSpanSecond : 1, + id: `area-c${idx + 1}`, + render: (cell: GridAreaCell) => , + rowSpan: idx === 0 ? rowSpanFirst : 1 + })) + + return ( + + ) +} + +function AreaDemoCell({ active, cell, label, t }: { active: boolean; cell: GridAreaCell; label: string; t: Theme }) { + const borderColor = active ? t.color.primary : t.color.border + const labelColor = active ? t.color.primary : t.color.label + + return ( + + + {label} + + + {cell.height >= 5 && ( + + {cell.colSpan > 1 || cell.rowSpan > 1 ? `${cell.colSpan}x${cell.rowSpan}` : `${cell.width}w`} + + )} + + ) +} + +function GridCell({ + active, + label, + nested, + nestedMode, + t, + width +}: { + active: boolean + label: string + nested: boolean + nestedMode: boolean + t: Theme + width: number +}) { + const padX = width >= 14 ? 1 : 0 + const inner = Math.max(1, width - 2 - padX * 2) + const borderColor = active ? t.color.primary : t.color.border + const height = nestedMode ? NESTED_CELL_HEIGHT : FLAT_CELL_HEIGHT + const labelColor = active ? t.color.primary : t.color.label + + return ( + + {nested && width >= 10 ? ( + <> + + + {label} + + + + + + ) : ( + + + {label} + + + )} + + ) +} + +function ZoomedGridCell({ cols, parentLabel, t }: { cols: number; parentLabel: string; t: Theme }) { + const childColumns = cols >= 72 ? 4 : 2 + const contentWidth = Math.max(1, cols - 4) + + return ( + + + parent {parentLabel} + + ), + id: 'header-title' + }, + { + children: ( + + nested child grid + + ), + id: 'header-meta' + } + ]} + /> + + + + + + ) +} + +const childCellWidgets = (t: Theme, count: number, columns: number): WidgetGridWidget[] => { + const colors = [t.color.ok, t.color.warn, t.color.accent, t.color.muted, t.color.primary] + // 3-cell preview: two side-by-side, third spans the full row. + const lastSpansRow = count === 3 + + return Array.from({ length: count }, (_, idx) => ({ + colSpan: lastSpansRow && idx === count - 1 ? columns : 1, + id: `child-c${idx + 1}`, + render: w => + })) +} + +function MiniCell({ color, label, width }: { color: string; label: string; width: number }) { + return ( + + {label} + + ) +} diff --git a/ui-tui/src/components/overlay.tsx b/ui-tui/src/components/overlay.tsx new file mode 100644 index 000000000000..58c7b93250de --- /dev/null +++ b/ui-tui/src/components/overlay.tsx @@ -0,0 +1,141 @@ +import { Box, Text, useStdout } from '@hermes/ink' +import { useStore } from '@nanostores/react' +import { type ReactNode } from 'react' + +import { $uiTheme } from '../app/uiStore.js' + +export type OverlayZone = + | 'bottom' + | 'bottom-left' + | 'bottom-right' + | 'center' + | 'left' + | 'right' + | 'top' + | 'top-left' + | 'top-right' + +interface OverlayProps { + /** Render a faux scrim behind the content (lipgloss-style: spaces + bg color). */ + backdrop?: boolean + /** Background color used to paint the scrim. Defaults to `theme.color.statusBg`. */ + backdropColor?: string + children: ReactNode + /** Nine CSS-grid-style zones. Defaults to `center`. */ + zone?: OverlayZone +} + +/** + * Viewport-level overlay primitive. Positions its child in one of nine zones + * and optionally paints a scrim behind it. + * + * Backdrop uses the canonical TUI pattern (cf. `lipgloss.Place`): each cell is + * a SPACE with a backgroundColor, so the area reads as a clean dimmed plane + * over the transcript. Ink only paints `backgroundColor` on cells with + * content, so the scrim is rendered as explicit lines of spaces — a `` + * with a bg alone would be invisible. Uses stdout dims so placement is + * deterministic regardless of tree depth. + */ +export function Overlay({ backdrop = false, backdropColor, children, zone = 'center' }: OverlayProps) { + const { stdout } = useStdout() + const theme = useStore($uiTheme) + const cols = stdout?.columns ?? 80 + const rows = stdout?.rows ?? 24 + const [justify, align] = zoneFlex(zone) + const scrimBg = backdropColor ?? theme.color.statusBg + const scrimLine = ' '.repeat(cols) + + return ( + <> + {backdrop && ( + + {Array.from({ length: rows }, (_, i) => ( + + {scrimLine} + + ))} + + )} + + + {children} + + + ) +} + +interface DialogProps { + children: ReactNode + hint?: ReactNode + title?: string + width?: number +} + +/** Bordered card with optional title + hint. Pair with `Overlay` for centered modals. */ +export function Dialog({ children, hint, title, width }: DialogProps) { + const theme = useStore($uiTheme) + const innerWidth = width !== undefined ? Math.max(1, width - 6) : undefined + + return ( + + {title && ( + + + {title} + + + )} + + {children} + + {hint && ( + {typeof hint === 'string' ? {hint} : hint} + )} + + ) +} + +const zoneFlex = (zone: OverlayZone): ['center' | 'flex-end' | 'flex-start', 'center' | 'flex-end' | 'flex-start'] => { + const horizontal = { + bottom: 'center', + 'bottom-left': 'flex-start', + 'bottom-right': 'flex-end', + center: 'center', + left: 'flex-start', + right: 'flex-end', + top: 'center', + 'top-left': 'flex-start', + 'top-right': 'flex-end' + } as const + + const vertical = { + bottom: 'flex-end', + 'bottom-left': 'flex-end', + 'bottom-right': 'flex-end', + center: 'center', + left: 'center', + right: 'center', + top: 'flex-start', + 'top-left': 'flex-start', + 'top-right': 'flex-start' + } as const + + return [horizontal[zone], vertical[zone]] +} diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx index 941ee0b27529..31c8f355f007 100644 --- a/ui-tui/src/components/skillsHub.tsx +++ b/ui-tui/src/components/skillsHub.tsx @@ -11,7 +11,7 @@ const VISIBLE = 12 const MIN_WIDTH = 40 const MAX_WIDTH = 90 -export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { +export function SkillsHub({ gw, maxWidth, onClose, t }: SkillsHubProps) { const [skillsByCat, setSkillsByCat] = useState>({}) const [selectedCat, setSelectedCat] = useState('') const [catIdx, setCatIdx] = useState(0) @@ -23,7 +23,10 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { const [loading, setLoading] = useState(true) const { stdout } = useStdout() - const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + const terminalWidth = Math.max(1, (stdout?.columns ?? 80) - 6) + const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, terminalWidth)) + const widthCap = Math.max(24, Math.trunc(maxWidth ?? preferredWidth)) + const width = Math.max(24, Math.min(preferredWidth, widthCap)) useEffect(() => { gw.request<{ skills?: Record }>('skills.manage', { action: 'list' }) @@ -303,6 +306,7 @@ interface SkillInfo { interface SkillsHubProps { gw: GatewayClient + maxWidth?: number onClose: () => void t: Theme } diff --git a/ui-tui/src/components/widgetGrid.tsx b/ui-tui/src/components/widgetGrid.tsx new file mode 100644 index 000000000000..291df66522fc --- /dev/null +++ b/ui-tui/src/components/widgetGrid.tsx @@ -0,0 +1,266 @@ +import { Box } from '@hermes/ink' +import { Fragment, memo, type ReactNode, useMemo } from 'react' + +import { + type GridAreaCell, + type GridAreaItem, + type GridTrackSize, + layoutGridAreas, + layoutWidgetGrid, + type WidgetGridCell, + type WidgetGridItem, + widgetGridSpanWidth +} from '../lib/widgetGrid.js' + +export interface WidgetGridRenderContext { + cell: WidgetGridCell + width: number +} + +type WidgetGridChildren = ((ctx: WidgetGridRenderContext) => ReactNode) | ReactNode + +/** + * A grid item with optional content. Use `children` for static or stateful + * React subtrees (including a nested `WidgetGrid`) and `render` for a width- + * aware factory; if both are provided, `render` wins. + */ +export interface WidgetGridWidget extends WidgetGridItem { + children?: WidgetGridChildren + render?: (width: number, cell: WidgetGridCell) => ReactNode +} + +/** + * `WidgetGrid` lays out children into rows/cols using the same primitives as + * CSS grid: explicit `columns` count or a width-derived auto count, per-item + * `colStart` / `colSpan`, and uniform `gap` / `rowGap`. Cells clip their + * contents (`overflow: hidden`) so child overflow can never bleed into the + * neighbouring cell or break the parent border. + */ +interface WidgetGridProps { + columns?: number + cols: number + depth?: number + gap?: number + maxColumns?: number + minColumnWidth?: number + paddingX?: number + paddingY?: number + rowGap?: number + widgets: WidgetGridWidget[] +} + +const toInt = (value: number, fallback: number) => (Number.isFinite(value) ? Math.trunc(value) : fallback) + +const inferredGap = (cols: number, columns: number | undefined, depth: number) => { + if (cols < 36 || (columns ?? 0) >= 8) { + return 0 + } + + if (depth > 0 || cols < 72 || (columns ?? 0) >= 4) { + return 1 + } + + return 2 +} + +const inferredPaddingX = (cols: number, depth: number) => { + if (depth <= 0 || cols < 24) { + return 0 + } + + return cols >= 56 ? 2 : 1 +} + +const inferredRowGap = (depth: number) => (depth > 0 ? 0 : 1) + +export const WidgetGrid = memo(function WidgetGrid({ + columns, + cols, + depth = 0, + gap, + maxColumns = 2, + minColumnWidth = 46, + paddingX, + paddingY, + rowGap, + widgets +}: WidgetGridProps) { + const safeCols = Math.max(1, toInt(cols, 1)) + const safePaddingX = Math.max(0, toInt(paddingX ?? inferredPaddingX(safeCols, depth), 0)) + const safePaddingY = Math.max(0, toInt(paddingY ?? 0, 0)) + const innerCols = Math.max(1, safeCols - safePaddingX * 2) + const safeGap = Math.max(0, toInt(gap ?? inferredGap(innerCols, columns, depth), 0)) + const safeRowGap = Math.max(0, toInt(rowGap ?? inferredRowGap(depth), 0)) + + const layout = useMemo( + () => + layoutWidgetGrid({ + columns, + gap: safeGap, + items: widgets.map(({ colSpan, colStart, id, span }) => ({ colSpan, colStart, id, span })), + maxColumns, + minColumnWidth, + width: innerCols + }), + [columns, innerCols, maxColumns, minColumnWidth, safeGap, widgets] + ) + + const widgetById = useMemo(() => new Map(widgets.map(widget => [widget.id, widget])), [widgets]) + + if (!layout.rows.length) { + return null + } + + return ( + + {layout.rows.map((row, rowIdx) => ( + + + + + + {safeRowGap > 0 && rowIdx < layout.rows.length - 1 ? : null} + + ))} + + ) +}) + +const WidgetRow = memo(function WidgetRow({ + cells, + columns, + gap, + widgetById +}: { + cells: WidgetGridCell[] + columns: number[] + gap: number + widgetById: Map +}) { + return ( + <> + {cells.map((cell, idx) => { + const cursor = idx === 0 ? 0 : cells[idx - 1]!.col + cells[idx - 1]!.span + + const spacerWidth = + cell.col === 0 + ? 0 + : cursor === 0 + ? widgetGridSpanWidth(columns, 0, cell.col, gap) + gap + : gap + (cell.col > cursor ? widgetGridSpanWidth(columns, cursor, cell.col - cursor, gap) + gap : 0) + + return ( + + {spacerWidth > 0 ? : null} + + + ) + })} + + ) +}) + +const WidgetCell = memo(function WidgetCell({ cell, widget }: { cell: WidgetGridCell; widget?: WidgetGridWidget }) { + const node = + widget?.render?.(cell.width, cell) ?? + (typeof widget?.children === 'function' ? widget.children({ cell, width: cell.width }) : widget?.children) ?? + null + + return ( + + {node} + + ) +}) + +// ── GridAreas: the two-axis workspace mode ────────────────────────────────── + +type GridAreaChildren = ((cell: GridAreaCell) => ReactNode) | ReactNode + +/** + * An area widget: placement (`col`/`row`/`colSpan`/`rowSpan`) plus content. + * `render` receives the solved cell (with `width`/`height` in terminal + * cells); `children` accepts a static subtree or a factory. `render` wins + * when both are given, mirroring `WidgetGridWidget`. + */ +export interface GridAreaWidget extends GridAreaItem { + children?: GridAreaChildren + render?: (cell: GridAreaCell) => ReactNode +} + +interface GridAreasProps { + /** Column tracks: a count (equal shares) or explicit fixed/`fr` tracks. */ + columns: GridTrackSize[] | number + gap?: number + height: number + rowGap?: number + /** Row tracks. Omitted: every row is an equal `fr` share of `height`. */ + rows?: GridTrackSize[] | number + widgets: GridAreaWidget[] + width: number +} + +/** + * `GridAreas` renders widgets into a fully two-dimensional grid: explicit + * column AND row tracks (fixed cells or weighted `fr` shares), `colSpan` / + * `rowSpan`, `col` / `row` pins, and dense auto-placement. Unlike the flowing + * `WidgetGrid` (which stacks rows and cannot express `rowSpan`), every cell + * here is solved to a rect and absolutely positioned inside a fixed-size box, + * so a cell can span rows the same way a merged FancyZones cell does on the + * desktop app. Requires a known `height`. + */ +export const GridAreas = memo(function GridAreas({ + columns, + gap = 1, + height, + rowGap = 0, + rows, + widgets, + width +}: GridAreasProps) { + const layout = useMemo( + () => + layoutGridAreas({ + columns, + gap, + height, + items: widgets.map(({ col, colSpan, id, row, rowSpan }) => ({ col, colSpan, id, row, rowSpan })), + rowGap, + rows, + width + }), + [columns, gap, height, rowGap, rows, widgets, width] + ) + + const widgetById = useMemo(() => new Map(widgets.map(widget => [widget.id, widget])), [widgets]) + + if (!layout.cells.length) { + return null + } + + return ( + + {layout.cells.map(cell => ( + + ))} + + ) +}) + +const AreaCell = memo(function AreaCell({ cell, widget }: { cell: GridAreaCell; widget?: GridAreaWidget }) { + const node = + widget?.render?.(cell) ?? (typeof widget?.children === 'function' ? widget.children(cell) : widget?.children) ?? null + + return ( + + {node} + + ) +}) diff --git a/ui-tui/src/lib/widgetGrid.ts b/ui-tui/src/lib/widgetGrid.ts new file mode 100644 index 000000000000..331861d77061 --- /dev/null +++ b/ui-tui/src/lib/widgetGrid.ts @@ -0,0 +1,495 @@ +export interface WidgetGridItem { + colSpan?: number + colStart?: number + id: string + span?: number +} + +export interface WidgetGridCell { + col: number + id: string + span: number + width: number +} + +export interface WidgetGridLayout { + columnCount: number + columns: number[] + rows: WidgetGridCell[][] +} + +export interface WidgetGridLayoutOptions { + columns?: number + gap?: number + items: WidgetGridItem[] + maxColumns?: number + minColumnWidth?: number + width: number +} + +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)) + +const toInt = (value: number, fallback: number) => { + if (!Number.isFinite(value)) { + return fallback + } + + return Math.trunc(value) +} + +const columnCountForWidth = (width: number, minColumnWidth: number, gap: number, maxColumns: number) => { + const safeWidth = Math.max(1, toInt(width, 1)) + const safeMinWidth = Math.max(1, toInt(minColumnWidth, 1)) + const safeGap = Math.max(0, toInt(gap, 0)) + const safeMaxColumns = Math.max(1, toInt(maxColumns, 1)) + const count = Math.floor((safeWidth + safeGap) / (safeMinWidth + safeGap)) + + return clamp(count || 1, 1, safeMaxColumns) +} + +const buildColumnWidths = (width: number, columnCount: number, gap: number) => + resolveGridTracks(width, gap, Array.from({ length: Math.max(1, toInt(columnCount, 1)) }, () => ({ fr: 1 }))) + +const spanWidth = (columns: number[], colStart: number, span: number, gap: number) => { + const end = Math.min(columns.length, colStart + span) + const width = columns.slice(colStart, end).reduce((acc, value) => acc + value, 0) + const safeGap = Math.max(0, toInt(gap, 0)) + + return width + safeGap * Math.max(0, end - colStart - 1) +} + +export const widgetGridSpanWidth = spanWidth + +const itemSpan = (item: WidgetGridItem, columnCount: number) => + clamp(toInt(item.colSpan ?? item.span ?? 1, 1), 1, columnCount) + +const itemColStart = (item: WidgetGridItem, columnCount: number, span: number) => { + if (item.colStart === undefined) { + return null + } + + return clamp(toInt(item.colStart, 0), 0, Math.max(0, columnCount - span)) +} + +const rangeIsFree = (occupied: boolean[], colStart: number, span: number) => { + for (let col = colStart; col < colStart + span; col++) { + if (occupied[col]) { + return false + } + } + + return true +} + +const occupyRange = (occupied: boolean[], colStart: number, span: number) => { + for (let col = colStart; col < colStart + span; col++) { + occupied[col] = true + } +} + +const firstFreeCol = (occupied: boolean[], span: number) => { + for (let col = 0; col <= occupied.length - span; col++) { + if (rangeIsFree(occupied, col, span)) { + return col + } + } + + return null +} + +const sortRow = (row: WidgetGridCell[]) => row.sort((a, b) => a.col - b.col) + +export function layoutWidgetGrid({ + columns: requestedColumns, + gap = 1, + items, + maxColumns = 3, + minColumnWidth = 28, + width +}: WidgetGridLayoutOptions): WidgetGridLayout { + const safeGap = Math.max(0, toInt(gap, 1)) + const safeWidth = Math.max(1, toInt(width, 1)) + const maxDrawableColumns = safeGap > 0 ? Math.max(1, Math.floor((safeWidth + safeGap) / (safeGap + 1))) : safeWidth + + const columnCount = + requestedColumns === undefined + ? columnCountForWidth(safeWidth, minColumnWidth, safeGap, maxColumns) + : clamp(toInt(requestedColumns, 1), 1, maxDrawableColumns) + + const columns = buildColumnWidths(width, columnCount, safeGap) + const rows: WidgetGridCell[][] = [] + let row: WidgetGridCell[] = [] + let occupied = Array.from({ length: columnCount }, () => false) + + const pushRow = () => { + rows.push(sortRow(row)) + row = [] + occupied = Array.from({ length: columnCount }, () => false) + } + + for (const item of items) { + const wantedSpan = itemSpan(item, columnCount) + const explicitCol = itemColStart(item, columnCount, wantedSpan) + let col = explicitCol ?? firstFreeCol(occupied, wantedSpan) + + if (col === null || (explicitCol !== null && !rangeIsFree(occupied, explicitCol, wantedSpan))) { + if (row.length > 0) { + pushRow() + } + + col = explicitCol ?? 0 + } + + row.push({ + col, + id: item.id, + span: wantedSpan, + width: spanWidth(columns, col, wantedSpan, safeGap) + }) + + occupyRange(occupied, col, wantedSpan) + } + + if (row.length > 0) { + rows.push(sortRow(row)) + } + + return { columnCount, columns, rows } +} + +// ── Track solver (grid-template-columns/rows for character cells) ────────── +// +// A track is either a fixed cell count (`number`) or a fractional share +// (`{ fr, min? }`) of whatever space the fixed tracks leave over — the same +// fixed-vs-flex split the desktop pane-shell's track model uses, solved in +// integer terminal cells. `min` defaults to 1 so a track never disappears. + +export type GridTrackSize = number | { fr: number; min?: number } + +const trackFr = (track: GridTrackSize) => (typeof track === 'number' ? 0 : Math.max(0.0001, track.fr)) + +const trackMin = (track: GridTrackSize) => (typeof track === 'number' ? 1 : Math.max(1, toInt(track.min ?? 1, 1))) + +/** Floor-divide `budget` across `weights`, spreading the remainder left-to-right. */ +const distributeByWeight = (budget: number, weights: number[]) => { + const total = weights.reduce((acc, w) => acc + w, 0) + + if (total <= 0 || budget <= 0) { + return weights.map(() => 0) + } + + const shares = weights.map(w => Math.floor((budget * w) / total)) + let remainder = budget - shares.reduce((acc, s) => acc + s, 0) + + for (let i = 0; remainder > 0 && i < shares.length; i++, remainder--) { + shares[i]! += 1 + } + + return shares +} + +/** + * Solve track sizes for a `total`-cell axis with `gap` cells between tracks. + * Fixed tracks take their size; `fr` tracks share the leftover by weight, + * re-pinning any track that falls below its `min` and re-solving the rest. + * Every track ends up ≥ 1 cell; when the axis genuinely can't fit, the + * overflow is shaved off the trailing tracks so the sum never exceeds the + * drawable width unless every track is already at 1. + */ +export function resolveGridTracks(total: number, gap: number, tracks: GridTrackSize[]): number[] { + const count = tracks.length + + if (!count) { + return [] + } + + const safeGap = Math.max(0, toInt(gap, 0)) + const usable = Math.max(count, toInt(total, count) - safeGap * (count - 1)) + const sizes = Array.from({ length: count }, () => 0) + let remaining = usable + let unpinned: number[] = [] + + tracks.forEach((track, idx) => { + if (typeof track === 'number') { + sizes[idx] = Math.max(1, toInt(track, 1)) + remaining -= sizes[idx]! + } else { + unpinned.push(idx) + } + }) + + while (unpinned.length) { + const shares = distributeByWeight(Math.max(0, remaining), unpinned.map(idx => trackFr(tracks[idx]!))) + const violating = unpinned.filter((idx, i) => shares[i]! < trackMin(tracks[idx]!)) + + if (!violating.length) { + unpinned.forEach((idx, i) => { + sizes[idx] = shares[i]! + }) + + break + } + + for (const idx of violating) { + sizes[idx] = trackMin(tracks[idx]!) + remaining -= sizes[idx]! + } + + unpinned = unpinned.filter(idx => !violating.includes(idx)) + } + + let overflow = sizes.reduce((acc, s) => acc + s, 0) - usable + + for (let idx = count - 1; idx >= 0 && overflow > 0; idx--) { + const give = Math.min(overflow, sizes[idx]! - 1) + + sizes[idx] -= give + overflow -= give + } + + return sizes +} + +// ── 2D area layout (the workspace mode) ──────────────────────────────────── +// +// `layoutGridAreas` is the full two-axis grid: explicit column AND row tracks, +// items with `col`/`row` pins and `colSpan`/`rowSpan`, dense first-fit +// auto-placement, and each cell solved to an absolute `{ x, y, width, height }` +// rect. It is the terminal-cell equivalent of the desktop zone editor's +// `GridLayout` (rowPercents / columnPercents / cellChildMap with merged +// spans) — the renderer absolutely positions each rect, which is what makes +// `rowSpan` representable at all under Yoga flexbox. + +export interface GridAreaItem { + id: string + /** Explicit column start (0-based). Explicitly pinned items may overlap. */ + col?: number + colSpan?: number + /** Explicit row start (0-based). Rows grow implicitly as needed. */ + row?: number + rowSpan?: number +} + +export interface GridAreaCell { + col: number + colSpan: number + height: number + id: string + row: number + rowSpan: number + width: number + x: number + y: number +} + +export interface GridAreasLayout { + cells: GridAreaCell[] + columnCount: number + columnSizes: number[] + height: number + rowCount: number + rowSizes: number[] + width: number +} + +export interface GridAreasOptions { + /** Column tracks — a count (equal `fr` shares) or explicit track list. */ + columns: GridTrackSize[] | number + gap?: number + height: number + items: GridAreaItem[] + rowGap?: number + /** Row tracks. Omitted or short: implicit rows are `{ fr: 1 }`. */ + rows?: GridTrackSize[] | number + width: number +} + +const normalizeTracks = (tracks: GridTrackSize[] | number | undefined, fallbackCount: number): GridTrackSize[] => { + if (Array.isArray(tracks) && tracks.length) { + return tracks + } + + const count = Math.max(1, toInt(typeof tracks === 'number' ? tracks : fallbackCount, 1)) + + return Array.from({ length: count }, () => ({ fr: 1 })) +} + +interface PlacedArea { + col: number + colSpan: number + id: string + row: number + rowSpan: number +} + +const ensureOccupancyRows = (occupied: boolean[][], rowCount: number, columnCount: number) => { + while (occupied.length < rowCount) { + occupied.push(Array.from({ length: columnCount }, () => false)) + } +} + +const areaIsFree = (occupied: boolean[][], row: number, col: number, rowSpan: number, colSpan: number) => { + ensureOccupancyRows(occupied, row + rowSpan, occupied[0]?.length ?? 1) + + for (let r = row; r < row + rowSpan; r++) { + for (let c = col; c < col + colSpan; c++) { + if (occupied[r]![c]) { + return false + } + } + } + + return true +} + +const occupyArea = (occupied: boolean[][], row: number, col: number, rowSpan: number, colSpan: number) => { + ensureOccupancyRows(occupied, row + rowSpan, occupied[0]?.length ?? 1) + + for (let r = row; r < row + rowSpan; r++) { + for (let c = col; c < col + colSpan; c++) { + occupied[r]![c] = true + } + } +} + +/** Dense first-fit auto-placement (CSS `grid-auto-flow: row dense`). */ +const placeGridItems = (items: GridAreaItem[], columnCount: number): PlacedArea[] => { + const occupied: boolean[][] = [Array.from({ length: columnCount }, () => false)] + + return items.map(item => { + const colSpan = clamp(toInt(item.colSpan ?? 1, 1), 1, columnCount) + const rowSpan = Math.max(1, toInt(item.rowSpan ?? 1, 1)) + const pinnedCol = item.col === undefined ? null : clamp(toInt(item.col, 0), 0, columnCount - colSpan) + const pinnedRow = item.row === undefined ? null : Math.max(0, toInt(item.row, 0)) + + let row: number + let col: number + + if (pinnedRow !== null && pinnedCol !== null) { + row = pinnedRow + col = pinnedCol + } else if (pinnedRow !== null) { + // Pinned row: first free column run, overlapping at col 0 when full. + col = 0 + + for (let c = 0; c <= columnCount - colSpan; c++) { + if (areaIsFree(occupied, pinnedRow, c, rowSpan, colSpan)) { + col = c + + break + } + } + + row = pinnedRow + } else { + // Auto (or pinned col): scan row-major for the first fitting rect. New + // rows are always empty, so the scan terminates. + row = 0 + col = pinnedCol ?? 0 + + for (let r = 0; ; r++) { + const found = + pinnedCol !== null + ? areaIsFree(occupied, r, pinnedCol, rowSpan, colSpan) + ? pinnedCol + : null + : (() => { + for (let c = 0; c <= columnCount - colSpan; c++) { + if (areaIsFree(occupied, r, c, rowSpan, colSpan)) { + return c + } + } + + return null + })() + + if (found !== null) { + row = r + col = found + + break + } + } + } + + occupyArea(occupied, row, col, rowSpan, colSpan) + + return { col, colSpan, id: item.id, row, rowSpan } + }) +} + +const trackOffsets = (sizes: number[], gap: number) => { + const offsets: number[] = [] + let cursor = 0 + + for (const size of sizes) { + offsets.push(cursor) + cursor += size + gap + } + + return offsets +} + +const spanSize = (sizes: number[], start: number, span: number, gap: number) => { + const end = Math.min(sizes.length, start + span) + + return sizes.slice(start, end).reduce((acc, s) => acc + s, 0) + gap * Math.max(0, end - start - 1) +} + +export function layoutGridAreas({ + columns, + gap = 1, + height, + items, + rowGap = 0, + rows, + width +}: GridAreasOptions): GridAreasLayout { + const safeGap = Math.max(0, toInt(gap, 1)) + const safeRowGap = Math.max(0, toInt(rowGap, 0)) + const safeWidth = Math.max(1, toInt(width, 1)) + const safeHeight = Math.max(1, toInt(height, 1)) + const columnTracks = normalizeTracks(columns, 1) + const columnCount = columnTracks.length + + const placed = placeGridItems(items, columnCount) + const placedRowCount = placed.reduce((acc, area) => Math.max(acc, area.row + area.rowSpan), 0) + const explicitRowTracks = rows === undefined ? [] : normalizeTracks(rows, 1) + const rowCount = Math.max(1, explicitRowTracks.length, placedRowCount) + + const rowTracks: GridTrackSize[] = Array.from( + { length: rowCount }, + (_, idx) => explicitRowTracks[idx] ?? { fr: 1 } + ) + + const columnSizes = resolveGridTracks(safeWidth, safeGap, columnTracks) + const rowSizes = resolveGridTracks(safeHeight, safeRowGap, rowTracks) + const columnStarts = trackOffsets(columnSizes, safeGap) + const rowStarts = trackOffsets(rowSizes, safeRowGap) + + const cells = placed.map(area => { + const row = Math.min(area.row, rowCount - 1) + + return { + col: area.col, + colSpan: area.colSpan, + height: spanSize(rowSizes, row, area.rowSpan, safeRowGap), + id: area.id, + row, + rowSpan: area.rowSpan, + width: spanSize(columnSizes, area.col, area.colSpan, safeGap), + x: columnStarts[area.col] ?? 0, + y: rowStarts[row] ?? 0 + } + }) + + return { + cells, + columnCount, + columnSizes, + height: safeHeight, + rowCount, + rowSizes, + width: safeWidth + } +} From 4a129af7098e3ef8aeac67648240c958ab2a73cd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:36:18 -0500 Subject: [PATCH 02/22] refactor(ui-tui): route production surfaces through the widget-grid engine Banner (responsive tiers: full logo -> compact rule -> text -> hidden), SessionPanel (fixed hero track + flexible info track, the desktop pane shell's fixed-vs-flex contract), floating overlays, prompt zone, and the pickers all render through WidgetGrid instead of hand-rolled flex math. Pickers gain maxWidth so grid cells can cap them. --- ui-tui/node_modules | 1 - .../src/components/activeSessionSwitcher.tsx | 6 +- ui-tui/src/components/appOverlays.tsx | 181 +++++++++++++----- ui-tui/src/components/branding.tsx | 141 ++++++++++---- ui-tui/src/components/modelPicker.tsx | 8 +- ui-tui/src/components/petPicker.tsx | 7 +- ui-tui/src/components/pluginsHub.tsx | 7 +- ui-tui/src/components/widgetGrid.tsx | 14 +- ui-tui/src/lib/widgetGrid.ts | 18 +- 9 files changed, 289 insertions(+), 94 deletions(-) delete mode 120000 ui-tui/node_modules diff --git a/ui-tui/node_modules b/ui-tui/node_modules deleted file mode 120000 index 001f2a89107d..000000000000 --- a/ui-tui/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/brooklyn/www/hermes-agent/ui-tui/node_modules \ No newline at end of file diff --git a/ui-tui/src/components/activeSessionSwitcher.tsx b/ui-tui/src/components/activeSessionSwitcher.tsx index 4ac3385420c1..e4ca9ee08509 100644 --- a/ui-tui/src/components/activeSessionSwitcher.tsx +++ b/ui-tui/src/components/activeSessionSwitcher.tsx @@ -283,6 +283,7 @@ function OrchestratorHintText({ segments, t }: OrchestratorHintTextProps) { export function ActiveSessionSwitcher({ currentSessionId, gw, + maxWidth, onCancel, onClose, onNew, @@ -318,7 +319,9 @@ export function ActiveSessionSwitcher({ const itemsRef = useRef([]) const historyDisplayRef = useRef([]) const { stdout } = useStdout() - const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + // Optional maxWidth lets grid layouts hand the switcher its cell budget. + const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth))) const promptColumns = Math.max(20, width - 11) // Rows are [new][live…][history…]: the "+ new" row is pinned first (index 0, @@ -893,6 +896,7 @@ interface OrchestratorHintTextProps { interface ActiveSessionSwitcherProps { currentSessionId: null | string gw: GatewayClient + maxWidth?: number onCancel: () => void onClose: (id: string) => Promise onNew: () => void diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 5075598db618..4c117be263b6 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -1,5 +1,6 @@ import { Box, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' +import type { ReactNode } from 'react' import { useGateway } from '../app/gatewayContext.js' import type { AppOverlaysProps } from '../app/interfaces.js' @@ -18,9 +19,42 @@ import { PluginsHub } from './pluginsHub.js' import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js' import { SkillsHub } from './skillsHub.js' import { SubscriptionOverlay } from './subscriptionOverlay.js' +import { WidgetGrid, type WidgetGridWidget } from './widgetGrid.js' const COMPLETION_WINDOW = 16 +/** + * A prompt hosted in a single-cell WidgetGrid with the classic 1-cell padding. + * The inner full-width column restores the horizontal stretch the old plain + * padded Box gave its child, so rendering is identical; routing through the + * grid makes the prompt zone a layout-engine surface like the desktop app's + * pane shell. + */ +function PromptCell({ children, cols, id }: { children: ReactNode; cols: number; id: string }) { + return ( + + + {children} + + ), + id + } + ]} + /> + + ) +} + export function PromptZone({ cols, onApprovalChoice, @@ -33,9 +67,9 @@ export function PromptZone({ if (overlay.approval) { return ( - + - + ) } @@ -48,9 +82,9 @@ export function PromptZone({ const onClose = () => patchOverlayState({ billing: null }) return ( - + - + ) } @@ -65,9 +99,9 @@ export function PromptZone({ const onClose = () => patchOverlayState({ subscription: null }) return ( - + - + ) } @@ -82,15 +116,15 @@ export function PromptZone({ const onCancel = () => patchOverlayState({ confirm: null }) return ( - + - + ) } if (overlay.clarify) { return ( - + - + ) } if (overlay.sudo) { return ( - + - + ) } if (overlay.secret) { return ( - + - + ) } @@ -178,19 +212,35 @@ export function FloatingOverlays({ const start = Math.max(0, Math.min(compIdx - Math.floor(COMPLETION_WINDOW / 2), completions.length - viewportSize)) - return ( - - {overlay.gridTest && ( - - - - )} + // Every floating panel is a widget in a single-column grid. Panels keep + // their intrinsic (content-hugging) widths inside full-width cells today; + // multi-column tiling on wide terminals is a `columns`/track change here, + // not a rewrite. `maxWidth` hands each panel its cell budget — with one + // column it never binds, so rendering is identical to the pre-grid layout. + const widgets: WidgetGridWidget[] = [] - {overlay.sessions && ( + const gridTest = overlay.gridTest + + if (gridTest) { + widgets.push({ + id: 'grid-test', + render: () => ( + + + + ) + }) + } + + if (overlay.sessions) { + widgets.push({ + id: 'sessions', + render: width => ( patchOverlayState({ sessions: false })} onClose={onActiveSessionClose} onNew={onNewLiveSession} @@ -200,66 +250,101 @@ export function FloatingOverlays({ t={theme} /> - )} + ) + }) + } - {overlay.modelPicker && ( + if (overlay.modelPicker) { + const initialRefresh = typeof overlay.modelPicker === 'object' && overlay.modelPicker.refresh === true + + widgets.push({ + id: 'model-picker', + render: width => ( patchOverlayState({ modelPicker: false })} onSelect={onModelSelect} sessionId={sid} t={theme} /> - )} + ) + }) + } - {overlay.petPicker && ( + if (overlay.petPicker) { + widgets.push({ + id: 'pet-picker', + render: width => ( - patchOverlayState({ petPicker: false })} t={theme} /> + patchOverlayState({ petPicker: false })} t={theme} /> - )} + ) + }) + } - {overlay.skillsHub && ( + if (overlay.skillsHub) { + widgets.push({ + id: 'skills-hub', + render: width => ( - patchOverlayState({ skillsHub: false })} t={theme} /> + patchOverlayState({ skillsHub: false })} t={theme} /> - )} + ) + }) + } - {overlay.pluginsHub && ( + if (overlay.pluginsHub) { + widgets.push({ + id: 'plugins-hub', + render: width => ( - patchOverlayState({ pluginsHub: false })} t={theme} /> + patchOverlayState({ pluginsHub: false })} t={theme} /> - )} + ) + }) + } - {overlay.pager && ( + const pager = overlay.pager + + if (pager) { + widgets.push({ + id: 'pager', + render: () => ( - {overlay.pager.title && ( + {pager.title && ( - {overlay.pager.title} + {pager.title} )} - {overlay.pager.lines.slice(overlay.pager.offset, overlay.pager.offset + pagerPageSize).map((line, i) => ( + {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( {line} ))} - {overlay.pager.offset + pagerPageSize < overlay.pager.lines.length - ? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})` - : `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`} + {pager.offset + pagerPageSize < pager.lines.length + ? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})` + : `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${pager.lines.length} lines)`} - )} + ) + }) + } - {!!completions.length && ( + if (completions.length) { + widgets.push({ + id: 'completions', + render: () => ( {completions.slice(start, start + viewportSize).map((item, i) => { @@ -295,7 +380,13 @@ export function FloatingOverlays({ })} - )} + ) + }) + } + + return ( + + ) } diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 3c9cdd5f2512..486e4d0b96a4 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -7,6 +7,8 @@ import { flat } from '../lib/text.js' import type { Theme } from '../theme.js' import type { PanelSection, SessionInfo } from '../types.js' +import { WidgetGrid } from './widgetGrid.js' + const LOADER_TICK_MS = 120 function InlineLoader({ label, t }: { label: string; t: Theme }) { @@ -94,19 +96,48 @@ export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) { const logoLines = logo(t.color, t.bannerLogo || undefined) const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH + // Each tier renders its rows through a single-column WidgetGrid sized to + // the available columns — same visual output as the old plain flex column + // (cells clip where truncate-end used to), but the banner is now a + // layout-engine surface. if (cols >= logoW + 2) { return ( - - - {t.brand.icon} {TAG_FULL} - + , id: 'banner-art' }, + { + children: ( + + {t.brand.icon} {TAG_FULL} + + ), + id: 'banner-tagline' + } + ]} + /> ) } if (cols >= COMPACT_FROM) { - return + return ( + , id: 'banner-compact' }]} + /> + ) } const name = cols >= 52 ? t.brand.name : (t.brand.name.split(' ')[0] ?? t.brand.name) @@ -114,12 +145,32 @@ export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) { return ( - - {t.brand.icon} {name} - - - {t.brand.icon} {tag} - + + {t.brand.icon} {name} + + ), + id: 'banner-name' + }, + { + children: ( + + {t.brand.icon} {tag} + + ), + id: 'banner-tag' + } + ]} + /> ) } @@ -282,32 +333,36 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { return {info.system_prompt} } - return ( - - {wide && ( - - - + // The wide layout is a real two-column grid: a fixed-width hero track and a + // flexible info track (grid-template-columns: 1fr, gap 2) — the + // terminal equivalent of the desktop pane shell's fixed-vs-flex tracks. + // Narrow drops to a single flexible track. Track math reproduces the old + // hand-rolled widths exactly: usable = (leftW + 2 + w) - gap = leftW + w. + const heroColumn = wide ? ( + + + - - {info.model.split('/').pop()} - · Nous Research - + + {info.model.split('/').pop()} + · Nous Research + - - {info.cwd || process.cwd()} - + + {info.cwd || process.cwd()} + - {sid && ( - - Session: - {sid} - - )} - + {sid && ( + + Session: + {sid} + )} + + ) : null - + const infoColumn = ( + {wide ? ( @@ -418,7 +473,27 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { ! {info.install_warning} )} - + + ) + + return ( + + ) } diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 3be32d211299..9983743ac9b2 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -34,6 +34,7 @@ export function ModelPicker({ allowPersistGlobal = true, gw, initialRefresh = false, + maxWidth, onCancel, onSelect, sessionId, @@ -57,8 +58,10 @@ export function ModelPicker({ // Pin the picker to a stable width so the FloatBox parent (which shrinks- // to-fit with alignSelf="flex-start") doesn't resize as long provider / // model names scroll into view, and so `wrap="truncate-end"` on each row - // has an actual constraint to truncate against. - const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + // has an actual constraint to truncate against. Optional maxWidth lets + // grid layouts hand the picker its cell budget. + const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth))) useEffect(() => { gw.request('model.options', { @@ -697,6 +700,7 @@ interface ModelPickerProps { allowPersistGlobal?: boolean gw: GatewayClient initialRefresh?: boolean + maxWidth?: number onCancel: () => void onSelect: (value: string) => void sessionId: string | null diff --git a/ui-tui/src/components/petPicker.tsx b/ui-tui/src/components/petPicker.tsx index dacb553082e3..f8ba4594362c 100644 --- a/ui-tui/src/components/petPicker.tsx +++ b/ui-tui/src/components/petPicker.tsx @@ -30,7 +30,7 @@ interface Gallery { * (install-on-demand). The mascot lights up live once `usePet` next polls — * no restart. This is the interactive sibling of the text `/pet ` path. */ -export function PetPicker({ gw, onClose, t }: PetPickerProps) { +export function PetPicker({ gw, maxWidth, onClose, t }: PetPickerProps) { const [gallery, setGallery] = useState(null) const [query, setQuery] = useState('') const [idx, setIdx] = useState(0) @@ -39,7 +39,9 @@ export function PetPicker({ gw, onClose, t }: PetPickerProps) { const [loading, setLoading] = useState(true) const { stdout } = useStdout() - const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + // Optional maxWidth lets grid layouts hand the picker its cell budget. + const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth))) useEffect(() => { gw.request('pet.gallery') @@ -178,6 +180,7 @@ export function PetPicker({ gw, onClose, t }: PetPickerProps) { interface PetPickerProps { gw: GatewayClient + maxWidth?: number onClose: () => void t: Theme } diff --git a/ui-tui/src/components/pluginsHub.tsx b/ui-tui/src/components/pluginsHub.tsx index 1c235a125c11..3876c831299a 100644 --- a/ui-tui/src/components/pluginsHub.tsx +++ b/ui-tui/src/components/pluginsHub.tsx @@ -39,7 +39,7 @@ const GLYPH: Record = { enabled: '✓' } -export function PluginsHub({ gw, onClose, t }: PluginsHubProps) { +export function PluginsHub({ gw, maxWidth, onClose, t }: PluginsHubProps) { const [rows, setRows] = useState([]) const [bundledCount, setBundledCount] = useState(0) const [userCount, setUserCount] = useState(0) @@ -50,7 +50,9 @@ export function PluginsHub({ gw, onClose, t }: PluginsHubProps) { const [loading, setLoading] = useState(true) const { stdout } = useStdout() - const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + // Optional maxWidth lets grid layouts hand the hub its cell budget. + const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth))) const load = () => { gw.request('plugins.manage', { action: 'list' }) @@ -233,6 +235,7 @@ export function PluginsHub({ gw, onClose, t }: PluginsHubProps) { interface PluginsHubProps { gw: GatewayClient + maxWidth?: number onClose: () => void t: Theme } diff --git a/ui-tui/src/components/widgetGrid.tsx b/ui-tui/src/components/widgetGrid.tsx index 291df66522fc..6867210d6974 100644 --- a/ui-tui/src/components/widgetGrid.tsx +++ b/ui-tui/src/components/widgetGrid.tsx @@ -37,7 +37,8 @@ export interface WidgetGridWidget extends WidgetGridItem { * neighbouring cell or break the parent border. */ interface WidgetGridProps { - columns?: number + /** Column count (equal shares) or grid-template-style track list. */ + columns?: GridTrackSize[] | number cols: number depth?: number gap?: number @@ -51,12 +52,17 @@ interface WidgetGridProps { const toInt = (value: number, fallback: number) => (Number.isFinite(value) ? Math.trunc(value) : fallback) -const inferredGap = (cols: number, columns: number | undefined, depth: number) => { - if (cols < 36 || (columns ?? 0) >= 8) { +const columnCountHint = (columns: GridTrackSize[] | number | undefined) => + Array.isArray(columns) ? columns.length : (columns ?? 0) + +const inferredGap = (cols: number, columns: GridTrackSize[] | number | undefined, depth: number) => { + const count = columnCountHint(columns) + + if (cols < 36 || count >= 8) { return 0 } - if (depth > 0 || cols < 72 || (columns ?? 0) >= 4) { + if (depth > 0 || cols < 72 || count >= 4) { return 1 } diff --git a/ui-tui/src/lib/widgetGrid.ts b/ui-tui/src/lib/widgetGrid.ts index 331861d77061..ae891eb857f8 100644 --- a/ui-tui/src/lib/widgetGrid.ts +++ b/ui-tui/src/lib/widgetGrid.ts @@ -19,7 +19,11 @@ export interface WidgetGridLayout { } export interface WidgetGridLayoutOptions { - columns?: number + /** + * Explicit column count (equal shares) or a grid-template-style track list + * (fixed cell counts / weighted `fr` shares). Omitted: auto from width. + */ + columns?: GridTrackSize[] | number gap?: number items: WidgetGridItem[] maxColumns?: number @@ -111,12 +115,18 @@ export function layoutWidgetGrid({ const safeWidth = Math.max(1, toInt(width, 1)) const maxDrawableColumns = safeGap > 0 ? Math.max(1, Math.floor((safeWidth + safeGap) / (safeGap + 1))) : safeWidth - const columnCount = - requestedColumns === undefined + const trackList = Array.isArray(requestedColumns) && requestedColumns.length ? requestedColumns : null + + const columnCount = trackList + ? trackList.length + : requestedColumns === undefined || Array.isArray(requestedColumns) ? columnCountForWidth(safeWidth, minColumnWidth, safeGap, maxColumns) : clamp(toInt(requestedColumns, 1), 1, maxDrawableColumns) - const columns = buildColumnWidths(width, columnCount, safeGap) + const columns = trackList + ? resolveGridTracks(safeWidth, safeGap, trackList) + : buildColumnWidths(width, columnCount, safeGap) + const rows: WidgetGridCell[][] = [] let row: WidgetGridCell[] = [] let occupied = Array.from({ length: columnCount }, () => false) From cd05498e2c72f361ece20ad5c8bfa74cc885e95a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:36:18 -0500 Subject: [PATCH 03/22] feat(ui-tui): background-aware theme adaptation + paired palettes + /theme pin OSC-11 asks the terminal for its actual background at startup (env heuristics are blind on xterm.js hosts) and the theme re-derives against the answer: desktop-contract adaptation (contrast floors + fill polarity), a shared list-row selection primitive instead of per-picker panel fills, paired light_colors/dark_colors skin blocks with a machine audit, and a /theme auto|light|dark pin (display.tui_theme) for hosts whose probe lies. E2E coverage for the OSC reply chain + /theme-info diagnostics. --- hermes_cli/skin_engine.py | 406 +++++++++++++++- package-lock.json | 442 ------------------ tests/hermes_cli/test_skin_engine.py | 7 +- tests/hermes_cli/test_skin_palettes.py | 196 ++++++++ tui_gateway/server.py | 17 + .../packages/hermes-ink/src/entry-exports.ts | 2 +- .../hermes-ink/src/ink/components/App.tsx | 32 +- .../src/ink/osc-response-chain.test.ts | 76 +++ .../src/ink/terminal-background.test.ts | 78 ++++ .../packages/hermes-ink/src/ink/terminal.ts | 98 ++++ .../__tests__/activeSessionSwitcher.test.ts | 13 +- .../createGatewayEventHandler.test.ts | 20 + ui-tui/src/__tests__/theme.test.ts | 123 ++++- ui-tui/src/app/createGatewayEventHandler.ts | 108 ++++- ui-tui/src/app/slash/commands/debug.ts | 29 ++ ui-tui/src/app/slash/commands/session.ts | 26 ++ ui-tui/src/app/useConfigSync.ts | 3 + .../src/components/activeSessionSwitcher.tsx | 8 +- ui-tui/src/components/appLayout.tsx | 1 + ui-tui/src/components/appOverlays.tsx | 15 +- ui-tui/src/components/overlayPrimitives.tsx | 21 +- ui-tui/src/components/textInput.tsx | 35 +- ui-tui/src/gatewayTypes.ts | 7 + ui-tui/src/theme.ts | 253 ++++++++-- ui-tui/src/types/hermes-ink.d.ts | 3 + 25 files changed, 1467 insertions(+), 552 deletions(-) create mode 100644 tests/hermes_cli/test_skin_palettes.py create mode 100644 ui-tui/packages/hermes-ink/src/ink/osc-response-chain.test.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 18d92cdd6e7d..58a22ae14a74 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -48,6 +48,16 @@ All fields are optional. Missing values inherit from the ``default`` skin. completion_menu_meta_bg: "#1a1a2e" # Completion meta column background completion_menu_meta_current_bg: "#333355" # Active completion meta background + # Optional paired palette for the opposite terminal polarity (mirrors the + # desktop app's colors/darkColors pairing). If `colors` above is authored + # for dark terminals, `light_colors` supplies the hand-tuned light-terminal + # variant (same keys); light-authored skins supply `dark_colors` instead. + # Without a paired block, the TUI adapts `colors` automatically + # (contrast-clamped foregrounds, polarity-corrected fills). + light_colors: + banner_title: "#8B6914" + # ... same keys as `colors` ... + # Spinner: customize the animated spinner during API calls spinner: waiting_faces: # Faces shown while waiting for API @@ -132,6 +142,14 @@ class SkinConfig: name: str description: str = "" colors: Dict[str, str] = field(default_factory=dict) + # Paired palettes for terminals whose background polarity differs from the + # one `colors` was authored against (mirrors the desktop app's + # colors/darkColors pairing). A consumer that knows the terminal is light + # prefers `light_colors` (falling back to `colors`), and vice versa for + # `dark_colors`. Both merge over the default skin's matching block, so + # partial user skins still resolve to a complete palette. + light_colors: Dict[str, str] = field(default_factory=dict) + dark_colors: Dict[str, str] = field(default_factory=dict) spinner: Dict[str, Any] = field(default_factory=dict) branding: Dict[str, str] = field(default_factory=dict) tool_prefix: str = "┊" @@ -165,6 +183,8 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "default": { "name": "default", "description": "Classic Hermes — gold and kawaii", + # Dark-authored. Values match the TUI's DARK_THEME so the classic CLI + # and the TUI render the same Hermes gold. "colors": { "banner_border": "#CD7F32", "banner_title": "#FFD700", @@ -180,8 +200,51 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "input_rule": "#CD7F32", "response_border": "#FFD700", "status_bar_bg": "#1a1a2e", + "status_bar_text": "#C0C0C0", + "status_bar_strong": "#FFD700", + "status_bar_dim": "#8A7A4A", + "status_bar_good": "#8FBC8F", + "status_bar_warn": "#FFD700", + "status_bar_bad": "#FF8C00", + "status_bar_critical": "#FF6B6B", "session_label": "#DAA520", "session_border": "#8B8682", + "completion_menu_bg": "#1a1a2e", + "completion_menu_current_bg": "#333355", + "selection_bg": "#3a3a55", + "shell_dollar": "#4dabf7", + "voice_status_bg": "#1a1a2e", + }, + # Hand-tuned light palette (mirrors the TUI's LIGHT_THEME golds). + "light_colors": { + "banner_border": "#7A4F1F", + "banner_title": "#8B6914", + "banner_accent": "#A0651C", + "banner_dim": "#7A5A0F", + "banner_text": "#3D2F13", + "ui_accent": "#A0651C", + "ui_label": "#7A5A0F", + "ui_ok": "#2E7D32", + "ui_error": "#C62828", + "ui_warn": "#E65100", + "prompt": "#2B2014", + "input_rule": "#7A4F1F", + "response_border": "#8B6914", + "status_bar_bg": "#F5F5F5", + "status_bar_text": "#333333", + "status_bar_strong": "#8B6914", + "status_bar_dim": "#8A8A8A", + "status_bar_good": "#2E7D32", + "status_bar_warn": "#8B6914", + "status_bar_bad": "#D84315", + "status_bar_critical": "#B71C1C", + "session_label": "#7A5A0F", + "session_border": "#7A5A0F", + "completion_menu_bg": "#F5F5F5", + "completion_menu_current_bg": "#E0D1BF", + "selection_bg": "#D4E4F7", + "shell_dollar": "#1565C0", + "voice_status_bg": "#F5F5F5", }, "spinner": { # Empty = use hardcoded defaults in display.py @@ -200,10 +263,10 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "name": "ares", "description": "War-god theme — crimson and bronze", "colors": { - "banner_border": "#9F1C1C", + "banner_border": "#A93333", "banner_title": "#C7A96B", "banner_accent": "#DD4A3A", - "banner_dim": "#6B1717", + "banner_dim": "#905151", "banner_text": "#F1E6CF", "ui_accent": "#DD4A3A", "ui_label": "#C7A96B", @@ -211,18 +274,53 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "ui_error": "#ef5350", "ui_warn": "#ffa726", "prompt": "#F1E6CF", - "input_rule": "#9F1C1C", + "input_rule": "#A93333", "response_border": "#C7A96B", "status_bar_bg": "#2A1212", "status_bar_text": "#F1E6CF", "status_bar_strong": "#C7A96B", - "status_bar_dim": "#6E584B", + "status_bar_dim": "#756054", "status_bar_good": "#7BC96F", "status_bar_warn": "#C7A96B", "status_bar_bad": "#DD4A3A", "status_bar_critical": "#EF5350", "session_label": "#C7A96B", "session_border": "#6E584B", + "completion_menu_bg": "#2A1212", + "completion_menu_current_bg": "#5C221D", + "selection_bg": "#692620", + "shell_dollar": "#DD4A3A", + "voice_status_bg": "#2A1212", + }, + "light_colors": { + "banner_border": "#A93333", + "banner_title": "#8B764B", + "banner_accent": "#D24637", + "banner_dim": "#905151", + "banner_text": "#3C3A34", + "ui_accent": "#D24637", + "ui_label": "#8B764B", + "ui_ok": "#39833C", + "ui_error": "#CB4744", + "ui_warn": "#BF7D1C", + "prompt": "#3C3A34", + "input_rule": "#A93333", + "response_border": "#9F8756", + "status_bar_bg": "#F5EEEF", + "status_bar_text": "#33373D", + "status_bar_strong": "#847047", + "status_bar_dim": "#8A8F98", + "status_bar_good": "#508348", + "status_bar_warn": "#9F8756", + "status_bar_bad": "#D24637", + "status_bar_critical": "#CB4744", + "session_label": "#9F8756", + "session_border": "#6E584B", + "completion_menu_bg": "#F5EEEF", + "completion_menu_current_bg": "#F0CAC7", + "selection_bg": "#F8DBD8", + "shell_dollar": "#D24637", + "voice_status_bg": "#F5EEEF", }, "spinner": { "waiting_faces": ["(⚔)", "(⛨)", "(▲)", "(<>)", "(/)"], @@ -272,10 +370,10 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "name": "mono", "description": "Monochrome — clean grayscale", "colors": { - "banner_border": "#555555", + "banner_border": "#5E5E5E", "banner_title": "#e6edf3", "banner_accent": "#aaaaaa", - "banner_dim": "#444444", + "banner_dim": "#606060", "banner_text": "#c9d1d9", "ui_accent": "#aaaaaa", "ui_label": "#888888", @@ -283,7 +381,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "ui_error": "#cccccc", "ui_warn": "#999999", "prompt": "#c9d1d9", - "input_rule": "#444444", + "input_rule": "#606060", "response_border": "#aaaaaa", "status_bar_bg": "#1F1F1F", "status_bar_text": "#C9D1D9", @@ -294,7 +392,42 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "status_bar_bad": "#D0D0D0", "status_bar_critical": "#F0F0F0", "session_label": "#888888", - "session_border": "#555555", + "session_border": "#5E5E5E", + "completion_menu_bg": "#1F1F1F", + "completion_menu_current_bg": "#464646", + "selection_bg": "#505050", + "shell_dollar": "#aaaaaa", + "voice_status_bg": "#1F1F1F", + }, + "light_colors": { + "banner_border": "#5E5E5E", + "banner_title": "#73767A", + "banner_accent": "#777777", + "banner_dim": "#606060", + "banner_text": "#323436", + "ui_accent": "#777777", + "ui_label": "#7A7A7A", + "ui_ok": "#7A7A7A", + "ui_error": "#7A7A7A", + "ui_warn": "#919191", + "prompt": "#323436", + "input_rule": "#606060", + "response_border": "#909090", + "status_bar_bg": "#F2F3F5", + "status_bar_text": "#33373D", + "status_bar_strong": "#73767A", + "status_bar_dim": "#8A8F98", + "status_bar_good": "#767676", + "status_bar_warn": "#909090", + "status_bar_bad": "#727272", + "status_bar_critical": "#787878", + "session_label": "#888888", + "session_border": "#5E5E5E", + "completion_menu_bg": "#F2F3F5", + "completion_menu_current_bg": "#E2E3E4", + "selection_bg": "#EEEEEE", + "shell_dollar": "#777777", + "voice_status_bg": "#F2F3F5", }, "spinner": {}, "branding": { @@ -314,7 +447,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "banner_border": "#4169e1", "banner_title": "#7eb8f6", "banner_accent": "#8EA8FF", - "banner_dim": "#4b5563", + "banner_dim": "#545E6B", "banner_text": "#c9d1d9", "ui_accent": "#7eb8f6", "ui_label": "#8EA8FF", @@ -327,13 +460,48 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "status_bar_bg": "#151C2F", "status_bar_text": "#C9D1D9", "status_bar_strong": "#7EB8F6", - "status_bar_dim": "#4B5563", + "status_bar_dim": "#5D6672", "status_bar_good": "#63D0A6", "status_bar_warn": "#E6A855", "status_bar_bad": "#F7A072", "status_bar_critical": "#FF7A7A", "session_label": "#7eb8f6", - "session_border": "#4b5563", + "session_border": "#545E6B", + "completion_menu_bg": "#151C2F", + "completion_menu_current_bg": "#324867", + "selection_bg": "#3A5375", + "shell_dollar": "#7eb8f6", + "voice_status_bg": "#151C2F", + }, + "light_colors": { + "banner_border": "#4169e1", + "banner_title": "#5278A0", + "banner_accent": "#6376B2", + "banner_dim": "#545E6B", + "banner_text": "#323436", + "ui_accent": "#5278A0", + "ui_label": "#6376B2", + "ui_ok": "#40876C", + "ui_error": "#A1684A", + "ui_warn": "#B88644", + "prompt": "#323436", + "input_rule": "#4169e1", + "response_border": "#6593C5", + "status_bar_bg": "#F0F4F9", + "status_bar_text": "#33373D", + "status_bar_strong": "#5278A0", + "status_bar_dim": "#8A8F98", + "status_bar_good": "#40876C", + "status_bar_warn": "#B88644", + "status_bar_bad": "#A1684A", + "status_bar_critical": "#BF5C5C", + "session_label": "#6593C5", + "session_border": "#545E6B", + "completion_menu_bg": "#F0F4F9", + "completion_menu_current_bg": "#C3DAF3", + "selection_bg": "#E5F1FD", + "shell_dollar": "#5278A0", + "voice_status_bg": "#F0F4F9", }, "spinner": {}, "branding": { @@ -361,16 +529,55 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "ui_error": "#B91C1C", "ui_warn": "#B45309", "prompt": "#111827", - "input_rule": "#93C5FD", + "input_rule": "#6E94BE", "response_border": "#2563EB", + "status_bar_bg": "#E5EDF8", + "status_bar_text": "#111827", + "status_bar_strong": "#2563EB", + "status_bar_dim": "#838890", + "status_bar_good": "#15803D", + "status_bar_warn": "#B45309", + "status_bar_bad": "#B45309", + "status_bar_critical": "#B91C1C", "session_label": "#1D4ED8", "session_border": "#64748B", - "status_bar_bg": "#E5EDF8", - "voice_status_bg": "#E5EDF8", "completion_menu_bg": "#F8FAFC", "completion_menu_current_bg": "#DBEAFE", "completion_menu_meta_bg": "#EEF2FF", "completion_menu_meta_current_bg": "#BFDBFE", + "selection_bg": "#D3E0FB", + "shell_dollar": "#2563EB", + "voice_status_bg": "#E5EDF8", + }, + "dark_colors": { + "banner_border": "#2563EB", + "banner_title": "#767B85", + "banner_accent": "#4A71E0", + "banner_dim": "#596677", + "banner_text": "#DBDCDE", + "ui_accent": "#3A72ED", + "ui_label": "#328A83", + "ui_ok": "#2C8C50", + "ui_error": "#CA5252", + "ui_warn": "#B45309", + "prompt": "#DBDCDE", + "input_rule": "#6E94BE", + "response_border": "#2563EB", + "status_bar_bg": "#161C2A", + "status_bar_text": "#D8DADC", + "status_bar_strong": "#5C8BF2", + "status_bar_dim": "#838890", + "status_bar_good": "#2C8C50", + "status_bar_warn": "#B45309", + "status_bar_bad": "#BC6421", + "status_bar_critical": "#CA5252", + "session_label": "#3460DC", + "session_border": "#64748B", + "completion_menu_bg": "#161C2A", + "completion_menu_current_bg": "#1A2E5A", + "selection_bg": "#1A3060", + "shell_dollar": "#3A72ED", + "voice_status_bg": "#161C2A", }, "spinner": {}, "branding": { @@ -400,14 +607,53 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "prompt": "#2C1810", "input_rule": "#8B6914", "response_border": "#8B6914", + "status_bar_bg": "#F5F0E8", + "status_bar_text": "#2C1810", + "status_bar_strong": "#8B4513", + "status_bar_dim": "#8A8F98", + "status_bar_good": "#2E7D32", + "status_bar_warn": "#E65100", + "status_bar_bad": "#DA4D00", + "status_bar_critical": "#C62828", "session_label": "#5C3D11", "session_border": "#A0845C", - "status_bar_bg": "#F5F0E8", - "voice_status_bg": "#F5F0E8", "completion_menu_bg": "#F5EFE0", "completion_menu_current_bg": "#E8DCC8", "completion_menu_meta_bg": "#F0E8D8", "completion_menu_meta_current_bg": "#DFCFB0", + "selection_bg": "#E8DAD0", + "shell_dollar": "#8B4513", + "voice_status_bg": "#F5F0E8", + }, + "dark_colors": { + "banner_border": "#8B6914", + "banner_title": "#8B7555", + "banner_accent": "#A7714B", + "banner_dim": "#8B7355", + "banner_text": "#DFDCDB", + "ui_accent": "#A7714B", + "ui_label": "#8B7555", + "ui_ok": "#428A46", + "ui_error": "#D15151", + "ui_warn": "#E65100", + "prompt": "#DFDCDB", + "input_rule": "#8B6914", + "response_border": "#8B6914", + "status_bar_bg": "#1C1B1D", + "status_bar_text": "#DAD6D5", + "status_bar_strong": "#A7714B", + "status_bar_dim": "#8A8F98", + "status_bar_good": "#428A46", + "status_bar_warn": "#E65100", + "status_bar_bad": "#DA4D00", + "status_bar_critical": "#D15151", + "session_label": "#7B623F", + "session_border": "#A0845C", + "completion_menu_bg": "#1C1B1D", + "completion_menu_current_bg": "#38261A", + "selection_bg": "#3B261A", + "shell_dollar": "#A7714B", + "voice_status_bg": "#1C1B1D", }, "spinner": {}, "branding": { @@ -427,7 +673,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "banner_border": "#2A6FB9", "banner_title": "#A9DFFF", "banner_accent": "#5DB8F5", - "banner_dim": "#153C73", + "banner_dim": "#44638F", "banner_text": "#EAF7FF", "ui_accent": "#5DB8F5", "ui_label": "#A9DFFF", @@ -440,13 +686,48 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "status_bar_bg": "#0F2440", "status_bar_text": "#EAF7FF", "status_bar_strong": "#A9DFFF", - "status_bar_dim": "#496884", + "status_bar_dim": "#52708A", "status_bar_good": "#6ED7B0", "status_bar_warn": "#5DB8F5", - "status_bar_bad": "#2A6FB9", + "status_bar_bad": "#3576BC", "status_bar_critical": "#D94F4F", "session_label": "#A9DFFF", "session_border": "#496884", + "completion_menu_bg": "#0F2440", + "completion_menu_current_bg": "#254D73", + "selection_bg": "#2A587F", + "shell_dollar": "#5DB8F5", + "voice_status_bg": "#0F2440", + }, + "light_colors": { + "banner_border": "#2A6FB9", + "banner_title": "#5D7B8C", + "banner_accent": "#3C789F", + "banner_dim": "#44638F", + "banner_text": "#3A3E40", + "ui_accent": "#3C789F", + "ui_label": "#5D7B8C", + "ui_ok": "#39833C", + "ui_error": "#CB4744", + "ui_warn": "#BF7D1C", + "prompt": "#3A3E40", + "input_rule": "#2A6FB9", + "response_border": "#4A93C4", + "status_bar_bg": "#EEF4F9", + "status_bar_text": "#33373D", + "status_bar_strong": "#5D7B8C", + "status_bar_dim": "#8A8F98", + "status_bar_good": "#42816A", + "status_bar_warn": "#4A93C4", + "status_bar_bad": "#3576BC", + "status_bar_critical": "#CE4B4B", + "session_label": "#6E91A6", + "session_border": "#496884", + "completion_menu_bg": "#EEF4F9", + "completion_menu_current_bg": "#CEE7F8", + "selection_bg": "#DFF1FD", + "shell_dollar": "#3C789F", + "voice_status_bg": "#EEF4F9", }, "spinner": { "waiting_faces": ["(≈)", "(Ψ)", "(∿)", "(◌)", "(◠)"], @@ -499,7 +780,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "banner_border": "#B7B7B7", "banner_title": "#F5F5F5", "banner_accent": "#E7E7E7", - "banner_dim": "#4A4A4A", + "banner_dim": "#5C5C5C", "banner_text": "#D3D3D3", "ui_accent": "#E7E7E7", "ui_label": "#D3D3D3", @@ -512,13 +793,48 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "status_bar_bg": "#202020", "status_bar_text": "#D3D3D3", "status_bar_strong": "#F5F5F5", - "status_bar_dim": "#656565", + "status_bar_dim": "#6D6D6D", "status_bar_good": "#B7B7B7", "status_bar_warn": "#D3D3D3", "status_bar_bad": "#E7E7E7", "status_bar_critical": "#F5F5F5", "session_label": "#919191", "session_border": "#656565", + "completion_menu_bg": "#202020", + "completion_menu_current_bg": "#585858", + "selection_bg": "#666666", + "shell_dollar": "#E7E7E7", + "voice_status_bg": "#202020", + }, + "light_colors": { + "banner_border": "#898989", + "banner_title": "#7A7A7A", + "banner_accent": "#747474", + "banner_dim": "#5C5C5C", + "banner_text": "#353535", + "ui_accent": "#747474", + "ui_label": "#747474", + "ui_ok": "#747474", + "ui_error": "#747474", + "ui_warn": "#898989", + "prompt": "#3D3D3D", + "input_rule": "#656565", + "response_border": "#898989", + "status_bar_bg": "#F5F6F8", + "status_bar_text": "#33373D", + "status_bar_strong": "#7A7A7A", + "status_bar_dim": "#8A8F98", + "status_bar_good": "#777777", + "status_bar_warn": "#898989", + "status_bar_bad": "#747474", + "status_bar_critical": "#7A7A7A", + "session_label": "#919191", + "session_border": "#656565", + "completion_menu_bg": "#F5F6F8", + "completion_menu_current_bg": "#D9DBDE", + "selection_bg": "#FAFAFA", + "shell_dollar": "#747474", + "voice_status_bg": "#F5F6F8", }, "spinner": { "waiting_faces": ["(◉)", "(◌)", "(◬)", "(⬤)", "(::)"], @@ -585,18 +901,50 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "status_bar_bg": "#2B160E", "status_bar_text": "#FFF0D4", "status_bar_strong": "#FFD39A", - "status_bar_dim": "#6C4724", + "status_bar_dim": "#826144", "status_bar_good": "#6BCB77", "status_bar_warn": "#F29C38", "status_bar_bad": "#E2832B", "status_bar_critical": "#EF5350", "session_label": "#FFD39A", - "session_border": "#6C4724", - "selection_bg": "#5A260D", + "session_border": "#7B593A", "completion_menu_bg": "#0B0503", "completion_menu_current_bg": "#4A1B07", "completion_menu_meta_bg": "#120806", "completion_menu_meta_current_bg": "#5A260D", + "selection_bg": "#5A260D", + "shell_dollar": "#F29C38", + "voice_status_bg": "#2B160E", + }, + "light_colors": { + "banner_border": "#C75B1D", + "banner_title": "#8C7455", + "banner_accent": "#A96D27", + "banner_dim": "#BB8342", + "banner_text": "#403C35", + "ui_accent": "#A96D27", + "ui_label": "#8C7455", + "ui_ok": "#39833C", + "ui_error": "#CB4744", + "ui_warn": "#BF7D1C", + "prompt": "#403C35", + "input_rule": "#C75B1D", + "response_border": "#C27D2D", + "status_bar_bg": "#F6F2EF", + "status_bar_text": "#33373D", + "status_bar_strong": "#8C7455", + "status_bar_dim": "#8A8F98", + "status_bar_good": "#46844D", + "status_bar_warn": "#C27D2D", + "status_bar_bad": "#AA6220", + "status_bar_critical": "#CB4744", + "session_label": "#A68964", + "session_border": "#7B593A", + "completion_menu_bg": "#F6F2EF", + "completion_menu_current_bg": "#F5DFC7", + "selection_bg": "#FCEBD7", + "shell_dollar": "#A96D27", + "voice_status_bg": "#F6F2EF", }, "spinner": { "waiting_faces": ["(✦)", "(▲)", "(◇)", "(<>)", "(🔥)"], @@ -703,10 +1051,20 @@ def _build_skin_config(data: Dict[str, Any]) -> SkinConfig: branding = dict(default.get("branding", {})) branding.update(branding_overrides) + # Paired palettes are NOT merged over the default skin's blocks: an empty + # block means "this skin has no hand-tuned variant for that polarity", and + # consumers (the TUI) fall back to `colors` + automatic adaptation. Merging + # the default's gold light palette under a crimson skin would be worse + # than adapting the crimson. + light_colors = _mapping_or_empty(data.get("light_colors"), section="light_colors", skin_name=skin_name) + dark_colors = _mapping_or_empty(data.get("dark_colors"), section="dark_colors", skin_name=skin_name) + return SkinConfig( name=skin_name, description=data.get("description", ""), colors=colors, + light_colors=light_colors, + dark_colors=dark_colors, spinner=spinner, branding=branding, tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "┊")), diff --git a/package-lock.json b/package-lock.json index 3236f2e7310a..177ffa33fe57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1927,448 +1927,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index ba4d4c4ce16e..0ae10660d48b 100644 --- a/tests/hermes_cli/test_skin_engine.py +++ b/tests/hermes_cli/test_skin_engine.py @@ -48,7 +48,12 @@ class TestBuiltinSkins: skin = load_skin("ares") assert skin.name == "ares" assert skin.tool_prefix == "╎" - assert skin.get_color("banner_border") == "#9F1C1C" + # Crimson identity: border stays red-dominant (exact values are owned + # by the palette audit in test_skin_palettes.py, which enforces + # contrast floors — don't pin literals here). + border = skin.get_color("banner_border") + r, g, b = (int(border[i:i + 2], 16) for i in (1, 3, 5)) + assert r > g and r > b, f"ares border lost its crimson: {border}" assert skin.get_color("response_border") == "#C7A96B" assert skin.get_color("session_label") == "#C7A96B" assert skin.get_color("session_border") == "#6E584B" diff --git a/tests/hermes_cli/test_skin_palettes.py b/tests/hermes_cli/test_skin_palettes.py new file mode 100644 index 000000000000..90290e38850d --- /dev/null +++ b/tests/hermes_cli/test_skin_palettes.py @@ -0,0 +1,196 @@ +"""Built-in skin palette audit: completeness + WCAG contrast, per polarity. + +Every built-in skin must be a complete, coherent palette with no accidental +fallbacks (a partial skin inherits the default skin's gold, which is how +"slate feels all over the place" happened), and every palette must be a +fixed point of the TUI's runtime readability adaptation — hand-tuned values +that already pass the same contrast floors the TUI enforces (strong >= 3.9, +soft >= 2.8, fills matching the background polarity). Mirrors the desktop +app's paired colors/darkColors contract. +""" + +import pytest + +from hermes_cli.skin_engine import _BUILTIN_SKINS + +# Union of the color keys consumed by the TUI (fromSkin) and the classic CLI +# (banner.py / display.py / prompt_toolkit overrides). completion_menu_meta_* +# intentionally excluded: they default to the base menu keys. +REQUIRED_KEYS = frozenset( + { + "banner_border", + "banner_title", + "banner_accent", + "banner_dim", + "banner_text", + "ui_accent", + "ui_label", + "ui_ok", + "ui_error", + "ui_warn", + "prompt", + "input_rule", + "response_border", + "status_bar_bg", + "status_bar_text", + "status_bar_strong", + "status_bar_dim", + "status_bar_good", + "status_bar_warn", + "status_bar_bad", + "status_bar_critical", + "session_label", + "session_border", + "completion_menu_bg", + "completion_menu_current_bg", + "selection_bg", + "shell_dollar", + "voice_status_bg", + } +) + +# Foreground roles and their minimum contrast against the palette's pole. +# Matches ui-tui/src/theme.ts STRONG/SOFT tiers. +STRONG_FG = ( + "banner_title", + "banner_accent", + "banner_text", + "ui_accent", + "ui_label", + "ui_ok", + "ui_error", + "prompt", + "status_bar_strong", + "status_bar_good", + "status_bar_bad", + "status_bar_critical", + "shell_dollar", +) +SOFT_FG = ( + "banner_dim", + "banner_border", + "ui_warn", + "input_rule", + "response_border", + "status_bar_dim", + "status_bar_warn", + "session_label", + "session_border", +) +# status_bar_text renders on status_bar_bg, not the terminal background. +ON_STATUS_BAR = ("status_bar_text", "status_bar_strong", "status_bar_dim") + +FILLS = ( + "status_bar_bg", + "completion_menu_bg", + "completion_menu_current_bg", + "selection_bg", + "voice_status_bg", +) + +STRONG_MIN = 3.9 +SOFT_MIN = 2.8 +# Assumed terminal poles, matching ui-tui/src/theme.ts referenceBackground(). +DARK_POLE = "#101014" +LIGHT_POLE = "#ffffff" + + +def _rgb(hex_color: str): + h = hex_color.lstrip("#") + assert len(h) == 6, f"not a 6-digit hex: {hex_color!r}" + return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) + + +def _channel(v: float) -> float: + c = v / 255 + return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 + + +def luminance(hex_color: str) -> float: + r, g, b = _rgb(hex_color) + return 0.2126 * _channel(r) + 0.7152 * _channel(g) + 0.0722 * _channel(b) + + +def contrast(a: str, b: str) -> float: + la, lb = luminance(a), luminance(b) + hi, lo = max(la, lb), min(la, lb) + return (hi + 0.05) / (lo + 0.05) + + +def _palettes(): + """Yield (skin, palette_name, palette, is_light) for every built-in.""" + for name, skin in _BUILTIN_SKINS.items(): + colors = skin.get("colors", {}) + light = skin.get("light_colors", {}) + dark = skin.get("dark_colors", {}) + + # `colors` polarity is declared by which paired block the skin ships. + colors_are_light = bool(dark) and not light + yield name, "colors", colors, colors_are_light + + if light: + yield name, "light_colors", light, True + if dark: + yield name, "dark_colors", dark, False + + +ALL_PALETTES = list(_palettes()) +PALETTE_IDS = [f"{skin}:{block}" for skin, block, _, _ in ALL_PALETTES] + + +def test_every_builtin_ships_a_paired_palette(): + for name, skin in _BUILTIN_SKINS.items(): + assert skin.get("light_colors") or skin.get("dark_colors"), ( + f"skin {name!r} has no paired palette: dark-authored skins need " + f"light_colors, light-authored skins need dark_colors" + ) + assert not (skin.get("light_colors") and skin.get("dark_colors")), ( + f"skin {name!r} declares both paired blocks; `colors` polarity " + f"would be ambiguous" + ) + + +@pytest.mark.parametrize(("skin", "block", "palette", "is_light"), ALL_PALETTES, ids=PALETTE_IDS) +def test_palette_is_complete(skin, block, palette, is_light): + missing = REQUIRED_KEYS - palette.keys() + assert not missing, f"{skin}.{block} missing keys: {sorted(missing)}" + + +@pytest.mark.parametrize(("skin", "block", "palette", "is_light"), ALL_PALETTES, ids=PALETTE_IDS) +def test_palette_contrast_and_polarity(skin, block, palette, is_light): + pole = LIGHT_POLE if is_light else DARK_POLE + problems = [] + + for key in STRONG_FG: + ratio = contrast(palette[key], pole) + if ratio < STRONG_MIN: + problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {STRONG_MIN} vs {pole}") + + for key in SOFT_FG: + ratio = contrast(palette[key], pole) + if ratio < SOFT_MIN: + problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {SOFT_MIN} vs {pole}") + + status_bg = palette["status_bar_bg"] + for key in ON_STATUS_BAR: + floor = STRONG_MIN if key == "status_bar_strong" else SOFT_MIN + ratio = contrast(palette[key], status_bg) + if ratio < floor: + problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {floor} vs status_bar_bg {status_bg}") + + for key in FILLS: + lum = luminance(palette[key]) + if is_light and lum < 0.4: + problems.append(f"{key}={palette[key]} is a dark fill (lum {lum:.2f}) in a light palette") + if not is_light and lum > 0.35: + problems.append(f"{key}={palette[key]} is a light fill (lum {lum:.2f}) in a dark palette") + + # The selection chip must remain distinguishable from the menu surface. + chip = contrast(palette["completion_menu_current_bg"], palette["completion_menu_bg"]) + if chip < 1.15: + problems.append( + f"completion_menu_current_bg={palette['completion_menu_current_bg']} " + f"indistinguishable from completion_menu_bg (contrast {chip:.2f})" + ) + + assert not problems, f"{skin}.{block}:\n " + "\n ".join(problems) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e13f34910477..7941481b0bed 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2400,6 +2400,10 @@ def resolve_skin() -> dict: return { "name": skin.name, "colors": skin.colors, + # Paired palettes: the TUI detects the terminal's polarity and + # prefers the matching hand-tuned block over adapting `colors`. + "light_colors": skin.light_colors, + "dark_colors": skin.dark_colors, "branding": skin.branding, "banner_logo": skin.banner_logo, "banner_hero": skin.banner_hero, @@ -11804,6 +11808,15 @@ def _(rid, params: dict) -> dict: _write_config_key("display.battery", nv_b) return _ok(rid, {"key": key, "value": "on" if nv_b else "off"}) + if key == "theme": + # TUI light/dark mode pin: 'light'/'dark' beat background + # auto-detection (xterm.js hosts misreport OSC 11); 'auto' trusts it. + raw = str(value or "").strip().lower() + if raw not in {"auto", "light", "dark"}: + return _err(rid, 4002, f"unknown theme value: {value} (use auto|light|dark)") + _write_config_key("display.tui_theme", raw) + return _ok(rid, {"key": key, "value": raw}) + if key == "statusbar": raw = str(value or "").strip().lower() display = _load_cfg().get("display") @@ -12645,6 +12658,10 @@ def _(rid, params: dict) -> dict: if key == "compact": on = bool((_load_cfg().get("display") or {}).get("tui_compact", False)) return _ok(rid, {"value": "on" if on else "off"}) + if key == "theme": + display = _load_cfg().get("display") + raw = str(display.get("tui_theme", "auto") if isinstance(display, dict) else "auto").strip().lower() + return _ok(rid, {"value": raw if raw in {"auto", "light", "dark"} else "auto"}) if key == "statusbar": display = _load_cfg().get("display") raw = ( diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index aaa849506ae8..57968a60e630 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -26,7 +26,7 @@ export { default as measureElement } from './ink/measure-element.js' export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js' export { createRoot, forceRedraw, default as render, renderSync } from './ink/root.js' export { stringWidth } from './ink/stringWidth.js' -export { isXtermJs } from './ink/terminal.js' +export { isXtermJs, onTerminalBackground, parseOscColor, terminalBackgroundHex } from './ink/terminal.js' export type { MouseTrackingMode } from './ink/termio/dec.js' export { wrapAnsi } from './ink/wrapAnsi.js' diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx index 68af579b6e0e..8602a5ba5e43 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -21,8 +21,8 @@ import { import reconciler from '../reconciler.js' import { clearSelection, finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js' import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js' -import { decrqm, TerminalQuerier, xtversion } from '../terminal-querier.js' -import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js' +import { decrqm, oscColor, TerminalQuerier, xtversion } from '../terminal-querier.js' +import { isXtermJs, parseOscColor, setTerminalBackgroundHex, setXtversionName, supportsExtendedKeys } from '../terminal.js' import { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, @@ -336,14 +336,28 @@ export default class App extends PureComponent { // init sequence completes — avoids interleaving with alt-screen/mouse // tracking enable writes that may happen in the same render cycle. setImmediate(() => { - void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => { - if (r) { - setXtversionName(r.name) - logForDebugging(`XTVERSION: terminal identified as "${r.name}"`) - } else { - logForDebugging('XTVERSION: no reply (terminal ignored query)') + // OSC 11 rides the same batch: the terminal's actual background + // color drives light/dark theme detection where env heuristics + // (COLORFGBG, TERM_PROGRAM) are blind — notably xterm.js hosts. + void Promise.all([this.querier.send(xtversion()), this.querier.send(oscColor(11)), this.querier.flush()]).then( + ([r, bg]) => { + if (r) { + setXtversionName(r.name) + logForDebugging(`XTVERSION: terminal identified as "${r.name}"`) + } else { + logForDebugging('XTVERSION: no reply (terminal ignored query)') + } + + const bgHex = bg ? parseOscColor(bg.data) : undefined + + if (bgHex) { + setTerminalBackgroundHex(bgHex) + logForDebugging(`OSC11: terminal background is ${bgHex}`) + } else { + logForDebugging('OSC11: no reply (terminal ignored query)') + } } - }) + ) }) // Re-assert mouse tracking on raw-mode re-entry. diff --git a/ui-tui/packages/hermes-ink/src/ink/osc-response-chain.test.ts b/ui-tui/packages/hermes-ink/src/ink/osc-response-chain.test.ts new file mode 100644 index 000000000000..ab4dddfea392 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/osc-response-chain.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' + +import { INITIAL_STATE, parseMultipleKeypresses } from './parse-keypress.js' +import { oscColor, TerminalQuerier } from './terminal-querier.js' +import { parseOscColor } from './terminal.js' + +// End-to-end (minus the PTY): a terminal's OSC 11 reply arriving on stdin +// must tokenize as a response, match the pending oscColor(11) query, and +// parse to a hex background. This is the exact chain App.tsx relies on for +// light/dark detection — if any segment drops the reply, detection dies +// silently because the querier never times out. + +const parseWire = (raw: string) => { + const [keys] = parseMultipleKeypresses({ ...INITIAL_STATE }, raw) + + return keys +} + +const drainResponses = (raw: string) => parseWire(raw).filter(k => k.kind === 'response') + +describe('OSC 11 reply chain', () => { + it.each([ + ['BEL-terminated', '\x1b]11;rgb:ffff/ffff/ffff\x07', '#ffffff'], + ['ST-terminated', '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\', '#1e1e2e'] + ])('resolves a pending query from a %s reply', async (_label, wire, expectedHex) => { + const writes: string[] = [] + const stdout = { write: (s: string) => (writes.push(s), true) } as unknown as NodeJS.WriteStream + const querier = new TerminalQuerier(stdout) + + const pending = querier.send(oscColor(11)) + + expect(writes.join('')).toContain('\x1b]11;?') + + const responses = drainResponses(wire) + + expect(responses).toHaveLength(1) + + for (const r of responses) { + if (r.kind === 'response') { + querier.onResponse(r.response) + } + } + + const reply = await pending + + expect(reply?.type).toBe('osc') + + if (reply?.type === 'osc') { + expect(reply.code).toBe(11) + expect(parseOscColor(reply.data)).toBe(expectedHex) + } + }) + + it('reply interleaved with keystrokes still resolves', async () => { + const stdout = { write: () => true } as unknown as NodeJS.WriteStream + const querier = new TerminalQuerier(stdout) + const pending = querier.send(oscColor(11)) + + const keys = parseWire('a\x1b]11;rgb:0000/0000/0000\x07b') + + for (const k of keys) { + if (k.kind === 'response') { + querier.onResponse(k.response) + } + } + + const reply = await pending + + expect(reply?.type).toBe('osc') + + // The surrounding keystrokes survive as normal input. + const chars = keys.filter(k => k.kind !== 'response') + + expect(chars.length).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts b/ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts new file mode 100644 index 000000000000..0cd1d0a9fef1 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { parseOscColor } from './terminal.js' + +// setTerminalBackgroundHex is first-writer-wins module state — re-import a +// fresh module instance per test so cases don't contaminate each other. +async function freshTerminal() { + vi.resetModules() + + return import('./terminal.js') +} + +beforeEach(() => { + vi.resetModules() +}) + +describe('parseOscColor', () => { + it('parses the canonical 16-bit X11 rgb: form', () => { + expect(parseOscColor('rgb:1e1e/2a2a/3f3f')).toBe('#1e2a3f') + expect(parseOscColor('rgb:ffff/ffff/ffff')).toBe('#ffffff') + expect(parseOscColor('rgb:0000/0000/0000')).toBe('#000000') + }) + + it('scales shorter per-channel widths to 8 bits', () => { + expect(parseOscColor('rgb:f/f/f')).toBe('#ffffff') + expect(parseOscColor('rgb:ff/ff/ff')).toBe('#ffffff') + expect(parseOscColor('rgb:fff/fff/fff')).toBe('#ffffff') + expect(parseOscColor('rgb:8/0/0')).toBe('#880000') + }) + + it('accepts rgba: (alpha ignored) and plain hex forms', () => { + expect(parseOscColor('rgba:1e1e/2a2a/3f3f/ffff')).toBe('#1e2a3f') + expect(parseOscColor('#1e2a3f')).toBe('#1e2a3f') + expect(parseOscColor('1e2a3f')).toBe('#1e2a3f') + expect(parseOscColor('#abc')).toBe('#aabbcc') + expect(parseOscColor('#1e1e2a2a3f3f')).toBe('#1e2a3f') + }) + + it('rejects garbage', () => { + expect(parseOscColor('')).toBeUndefined() + expect(parseOscColor('rgb:zz/zz/zz')).toBeUndefined() + expect(parseOscColor('rgb:1e1e/2a2a')).toBeUndefined() + expect(parseOscColor('notacolor')).toBeUndefined() + }) +}) + +describe('terminal background storage', () => { + it('fires queued listeners once when the background arrives', async () => { + const t = await freshTerminal() + const seen: string[] = [] + + t.onTerminalBackground(hex => seen.push(hex)) + expect(t.terminalBackgroundHex()).toBeUndefined() + + t.setTerminalBackgroundHex('#ffffff') + expect(seen).toEqual(['#ffffff']) + expect(t.terminalBackgroundHex()).toBe('#ffffff') + }) + + it('fires immediately for listeners registered after the answer', async () => { + const t = await freshTerminal() + + t.setTerminalBackgroundHex('#1e1e2e') + + const seen: string[] = [] + + t.onTerminalBackground(hex => seen.push(hex)) + expect(seen).toEqual(['#1e1e2e']) + }) + + it('is first-writer-wins (re-probe defense)', async () => { + const t = await freshTerminal() + + t.setTerminalBackgroundHex('#ffffff') + t.setTerminalBackgroundHex('#000000') + expect(t.terminalBackgroundHex()).toBe('#ffffff') + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal.ts b/ui-tui/packages/hermes-ink/src/ink/terminal.ts index 16e30e5e35e9..e3318e0fded4 100644 --- a/ui-tui/packages/hermes-ink/src/ink/terminal.ts +++ b/ui-tui/packages/hermes-ink/src/ink/terminal.ts @@ -172,6 +172,104 @@ export function needsAltScreenResizeScrollbackClear(env: NodeJS.ProcessEnv = pro return (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal' } +// -- OSC-11-detected terminal background (populated async at startup) -- +// +// Env heuristics (COLORFGBG, TERM_PROGRAM allow-lists) can't see the actual +// terminal background — xterm.js hosts (VS Code / Cursor) set neither, so a +// light-themed editor terminal reads as "dark" and gets an unreadable +// palette. OSC 11 asks the terminal directly; App.tsx fires the query in the +// same startup batch as XTVERSION and calls setTerminalBackgroundHex() when +// the reply lands. Readers treat undefined as "not yet known / unsupported". + +let terminalBackground: string | undefined +const terminalBackgroundListeners = new Set<(hex: string) => void>() + +/** Record the OSC 11 response. First writer wins (defend against re-probe). */ +export function setTerminalBackgroundHex(hex: string): void { + if (terminalBackground !== undefined) { + return + } + + terminalBackground = hex + + for (const listener of terminalBackgroundListeners) { + listener(hex) + } + + terminalBackgroundListeners.clear() +} + +/** The terminal's reported background as `#rrggbb`, or undefined if the + * reply hasn't arrived (or the terminal ignored the query). */ +export function terminalBackgroundHex(): string | undefined { + return terminalBackground +} + +/** Subscribe to the background color. Fires immediately when already known, + * otherwise once when the OSC 11 reply arrives. */ +export function onTerminalBackground(listener: (hex: string) => void): void { + if (terminalBackground !== undefined) { + listener(terminalBackground) + + return + } + + terminalBackgroundListeners.add(listener) +} + +/** + * Parse an OSC color reply payload into `#rrggbb`. + * + * Terminals answer OSC 10/11 queries with X11 color specs: most commonly + * `rgb:RRRR/GGGG/BBBB` (1-4 hex digits per channel, scaled to the channel + * max), sometimes `rgba:...` (alpha ignored) or a plain `#hex` form. + * Returns undefined for anything unrecognized. + */ +export function parseOscColor(data: string): string | undefined { + const value = data.trim().toLowerCase() + + const scaled = (component: string): null | number => { + if (!/^[0-9a-f]{1,4}$/.test(component)) { + return null + } + + const max = 16 ** component.length - 1 + + return Math.round((parseInt(component, 16) / max) * 255) + } + + const rgbMatch = /^rgba?:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})(?:\/[0-9a-f]{1,4})?$/.exec(value) + + if (rgbMatch) { + const channels = [rgbMatch[1]!, rgbMatch[2]!, rgbMatch[3]!].map(scaled) + + if (channels.every(c => c !== null)) { + return '#' + channels.map(c => c!.toString(16).padStart(2, '0')).join('') + } + + return undefined + } + + const hexMatch = /^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{12})$/.exec(value) + + if (!hexMatch) { + return undefined + } + + const hex = hexMatch[1]! + + if (hex.length === 6) { + return `#${hex}` + } + + if (hex.length === 3) { + return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` + } + + // 12-digit form: 4 digits per channel, take the top byte of each. + return `#${hex.slice(0, 2)}${hex.slice(4, 6)}${hex.slice(8, 10)}` +} + // Terminals known to correctly implement the Kitty keyboard protocol // (CSI >1u) and/or xterm modifyOtherKeys (CSI >4;2m) for ctrl+shift+ // disambiguation. We previously enabled unconditionally (#23350), assuming diff --git a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts index d598f6a834c6..175ee05bfc45 100644 --- a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts +++ b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts @@ -26,6 +26,7 @@ import { sessionRowKindAt, sessionsCountLabel } from '../components/activeSessionSwitcher.js' +import { listRowStyle } from '../components/overlayPrimitives.js' import type { SessionActiveItem } from '../gatewayTypes.js' import type { SessionListItem } from '../gatewayTypes.js' import { DEFAULT_THEME } from '../theme.js' @@ -75,13 +76,19 @@ describe('session orchestrator helpers', () => { expect(newSessionMarkerColor(DEFAULT_THEME, true)).toBe(DEFAULT_THEME.color.text) }) - it('uses a readable selected row style instead of accent-on-accent inverse text', () => { + it('uses the shared list-row primitive for the selected row (same as completions)', () => { const style = selectedSessionRowStyle(DEFAULT_THEME) + const shared = listRowStyle(DEFAULT_THEME, true) - expect(style.backgroundColor).toBe(DEFAULT_THEME.color.selectionBg) - expect(style.color).toBe(DEFAULT_THEME.color.text) + // One source of truth: the session switcher and the completions popover + // cannot disagree about what "selected" looks like. + expect(style.backgroundColor).toBe(shared.backgroundColor) + expect(style.color).toBe(shared.color) + // Readability contract survives: never accent-on-accent inverse. expect(style.backgroundColor).not.toBe(DEFAULT_THEME.color.accent) expect(style.color).not.toBe(DEFAULT_THEME.color.accent) + // Inactive rows paint nothing — the terminal's canvas is the row bg. + expect(listRowStyle(DEFAULT_THEME, false)).toEqual({}) }) it('turns model picker values into session-scoped draft model args', () => { diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index daf8617c3091..ff584695a8f2 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -723,6 +723,26 @@ describe('createGatewayEventHandler', () => { expect(ctx.gateway.rpc).not.toHaveBeenCalled() }) + it('picks the polarity-matching paired palette from gateway.ready skins', async () => { + const appended: Msg[] = [] + + const skin = { + colors: { banner_title: '#00FF88', banner_text: '#FFF8DC' }, + light_colors: { banner_title: '#8B0000', banner_text: '#22201C' } + } + + // Dark terminal (clean env): the dark-authored `colors` block wins. + vi.stubEnv('HERMES_TUI_BACKGROUND', '') + createGatewayEventHandler(buildCtx(appended))({ payload: skin, type: 'skin.changed' } as any) + expect(getUiState().theme.color.primary).toBe('#00FF88') + + // Light terminal: the hand-tuned light_colors block wins over adaptation. + vi.stubEnv('HERMES_TUI_BACKGROUND', '#ffffff') + createGatewayEventHandler(buildCtx(appended))({ payload: skin, type: 'skin.changed' } as any) + expect(getUiState().theme.color.primary).toBe('#8B0000') + vi.unstubAllEnvs() + }) + it('on gateway.ready with no STARTUP_RESUME_ID and auto_resume off, forges a new session', async () => { const appended: Msg[] = [] const newSession = vi.fn() diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index 6e356ab3a954..8e75a7861e2a 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -200,8 +200,10 @@ describe('fromSkin', () => { expect(fromSkin({ banner_title: '#FF0000' }, {}).color.accent).toBe(DEFAULT_THEME.color.accent) }) - it('derives completion current background from resolved completion background', async () => { - const { fromSkin } = await importThemeWithCleanEnv() + it('derives completion current background from resolved completion background (polarity-compatible)', async () => { + // Light terminal + light-authored menu fill: the skin's fill is honored + // and the current-row derivation mixes off it. + const { fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' }) const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {}) @@ -209,6 +211,17 @@ describe('fromSkin', () => { expect(theme.color.completionCurrentBg).toBe('#bfbfbf') }) + it('rejects wrong-polarity fills even when skin-authored (terminal owns the canvas)', async () => { + // Dark terminal + white menu fill: unlike the desktop app, the TUI cannot + // paint its own canvas, so cross-polarity fills fall back to the base. + const { DARK_THEME, fromSkin } = await importThemeWithCleanEnv() + + const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {}) + + expect(theme.color.completionBg).toBe(DARK_THEME.color.completionBg) + expect(theme.color.completionCurrentBg).toBe(DARK_THEME.color.completionCurrentBg) + }) + it('uses active completion color as the selection highlight fallback', async () => { const { fromSkin } = await importThemeWithCleanEnv() @@ -286,11 +299,15 @@ describe('fromSkin', () => { expect(theme.color.prompt).toBe('ansi256(136)') }) - it('does not normalize light Apple Terminal when truecolor is advertised', async () => { + it('keeps truecolor light Apple Terminal in truecolor (adapting, not ansi256-bucketing)', async () => { const { fromSkin } = await importThemeWithEnv({ COLORTERM: 'truecolor', TERM_PROGRAM: 'Apple_Terminal' }) const theme = fromSkin({ banner_text: '#FFF8DC' }, {}) - expect(theme.color.text).toBe('#FFF8DC') + // No ansi256 bucketing on truecolor terminals — but a cream foreground on + // a light background is exactly the washout the adaptation exists to fix, + // so the value is clamped to a readable truecolor hex rather than kept. + expect(theme.color.text).toMatch(/^#[0-9a-f]{6}$/i) + expect(luminance(theme.color.text)).toBeLessThanOrEqual(0.45) }) it('normalizes Apple Terminal names before matching', async () => { @@ -311,7 +328,101 @@ describe('fromSkin', () => { const { fromSkin } = await importThemeWithCleanEnv() const { color } = fromSkin({ ui_ok: '#008000' }, {}) - expect(color.ok).toBe('#008000') - expect(color.statusGood).toBe('#008000') + // The exact value may be contrast-lifted against the background; the + // contract is the cascade (ok drives statusGood) and the hue surviving. + expect(color.statusGood).toBe(color.ok) + expect(color.ok).toMatch(/^#[0-9a-f]{6}$/i) + expect(luminance(color.ok)).toBeGreaterThan(0) + }) +}) + +// Rec. 709-ish relative luminance, local to the test so assertions are +// independent of the implementation under test. +const luminance = (hex: string): number => { + const n = parseInt(hex.replace('#', ''), 16) + + const channel = (v: number) => { + const c = v / 255 + + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 + } + + return 0.2126 * channel((n >> 16) & 0xff) + 0.7152 * channel((n >> 8) & 0xff) + 0.0722 * channel(n & 0xff) +} + +// The bundled slate skin's actual color block — dark-authored (pale pastels, +// no completion/selection backgrounds defined). +const SLATE_COLORS = { + banner_accent: '#8EA8FF', + banner_border: '#4169e1', + banner_dim: '#4b5563', + banner_text: '#c9d1d9', + banner_title: '#7eb8f6', + prompt: '#c9d1d9', + session_border: '#4b5563', + session_label: '#7eb8f6', + ui_accent: '#7eb8f6', + ui_error: '#F7A072', + ui_label: '#8EA8FF', + ui_ok: '#63D0A6', + ui_warn: '#e6a855' +} + +describe('background-aware adaptation (OSC-11 light terminals)', () => { + it('keeps a dark-authored skin readable on a light background', async () => { + const { contrastRatio, fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' }) + const { color } = fromSkin(SLATE_COLORS, {}) + + // Foreground roles must clear WCAG contrast against the actual white + // background — hue survives, washout doesn't. + for (const key of ['text', 'prompt', 'accent', 'label', 'ok', 'error', 'primary'] as const) { + expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(3.8) + } + + // Softer roles (muted/warn/border) get a looser but real floor. + for (const key of ['muted', 'warn', 'border'] as const) { + expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(2.7) + } + + // Background roles the skin never defined must be light-polarity fills, + // not the dark base's navy. + for (const key of ['completionBg', 'completionCurrentBg', 'statusBg', 'selectionBg'] as const) { + expect(luminance(color[key]), `${key} ${color[key]}`).toBeGreaterThanOrEqual(0.4) + } + }) + + it('leaves the same skin untouched on a dark background', async () => { + const { fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#1e1e2e' }) + const { color } = fromSkin(SLATE_COLORS, {}) + + expect(color.text).toBe('#c9d1d9') + expect(color.accent).toBe('#7eb8f6') + expect(luminance(color.completionBg)).toBeLessThanOrEqual(0.35) + }) + + it('empty skin on a light background resolves to the light base palette', async () => { + const { fromSkin, LIGHT_THEME } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' }) + + expect(fromSkin({}, {}).color).toEqual(LIGHT_THEME.color) + }) + + it('base palettes are fixed points of the adaptation', async () => { + const dark = await importThemeWithCleanEnv() + + expect(dark.fromSkin({}, {}).color).toEqual(dark.DARK_THEME.color) + + const light = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#fafafa' }) + + expect(light.fromSkin({}, {}).color).toEqual(light.LIGHT_THEME.color) + }) + + it('defaultThemeForCurrentBackground follows a late HERMES_TUI_BACKGROUND write', async () => { + const { DEFAULT_THEME, defaultThemeForCurrentBackground, LIGHT_THEME } = await importThemeWithCleanEnv() + + // Module loaded dark (clean env)… + expect(DEFAULT_THEME.color.completionBg).toBe('#1a1a2e') + + // …then the OSC-11 answer lands and is cached into the env slot. + expect(defaultThemeForCurrentBackground({ HERMES_TUI_BACKGROUND: '#ffffff' }).color).toEqual(LIGHT_THEME.color) }) }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 4906290ac4c0..bcf70f0f577f 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,3 +1,5 @@ +import { onTerminalBackground } from '@hermes/ink' + import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js' import { STREAM_BATCH_MS } from '../config/timing.js' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' @@ -14,7 +16,7 @@ import { openExternalUrl } from '../lib/openExternalUrl.js' import { rpcErrorMessage } from '../lib/rpc.js' import { topLevelSubagents } from '../lib/subagentTree.js' import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js' -import { fromSkin } from '../theme.js' +import { defaultThemeForCurrentBackground, detectLightMode, fromSkin } from '../theme.js' import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' @@ -29,17 +31,99 @@ const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready') -const applySkin = (s: GatewaySkin) => - patchUiState({ - theme: fromSkin( - s.colors ?? {}, - s.branding ?? {}, - s.banner_logo ?? '', - s.banner_hero ?? '', - s.tool_prefix ?? '', - s.help_header ?? '' - ) +// The last gateway skin, kept so the theme can be re-derived when the OSC-11 +// background answer arrives after (or without) gateway.ready. +let lastSkin: GatewaySkin | null = null + +const themeForSkin = (s: GatewaySkin) => { + // Prefer the skin's hand-tuned palette for the terminal's polarity + // (desktop colors/darkColors contract). Without a paired block, `colors` + // goes through fromSkin's automatic contrast adaptation instead. + const paired = detectLightMode() ? s.light_colors : s.dark_colors + const colors = paired && Object.keys(paired).length ? paired : (s.colors ?? {}) + + return fromSkin(colors, s.branding ?? {}, s.banner_logo ?? '', s.banner_hero ?? '', s.tool_prefix ?? '', s.help_header ?? '') +} + +const applySkin = (s: GatewaySkin) => { + lastSkin = s + patchUiState({ theme: themeForSkin(s) }) +} + +/** Re-derive the theme from current detection signals (env overrides, cached + * OSC-11 answer) — used by /theme, config sync, and the OSC listener. */ +export function reapplyTheme(): void { + patchUiState({ theme: lastSkin ? themeForSkin(lastSkin) : defaultThemeForCurrentBackground() }) +} + +/** + * Apply the persisted mode pin (`display.tui_theme`). 'light'/'dark' bridge + * to HERMES_TUI_THEME — the priority-2 signal `detectLightMode` already + * honors (only an explicit HERMES_TUI_LIGHT env var outranks it); 'auto' + * clears the pin so the OSC-11 probe + env heuristics decide. The pin exists + * because the probe cannot always be trusted: xterm.js hosts report #000000 + * regardless of the painted background when the editor theme leaves the + * terminal background unset. + */ +export function applyConfiguredTuiTheme(raw: unknown): void { + const mode = String(raw ?? '') + .trim() + .toLowerCase() + + const current = process.env.HERMES_TUI_THEME ?? '' + + if (mode === 'light' || mode === 'dark') { + if (current === mode) { + return + } + + process.env.HERMES_TUI_THEME = mode + } else { + if (!current) { + return + } + + delete process.env.HERMES_TUI_THEME + } + + reapplyTheme() +} + +let themeBackgroundSyncStarted = false + +/** + * Re-derive the theme from the terminal's ACTUAL background color once the + * OSC-11 probe answers. The env heuristics `detectLightMode` runs at module + * load are blind in xterm.js hosts (VS Code / Cursor set no COLORFGBG), so a + * light editor terminal otherwise gets the dark fallback palette. The answer + * is cached into HERMES_TUI_BACKGROUND — the slot `detectLightMode` already + * reads (and child processes inherit) — then the current skin (or the + * skinless default) is re-applied against the corrected base. Explicit + * HERMES_TUI_LIGHT / HERMES_TUI_THEME overrides still win inside + * detectLightMode, so users can pin a mode regardless of the probe. + */ +export function syncThemeToTerminalBackground(): void { + if (themeBackgroundSyncStarted) { + return + } + + themeBackgroundSyncStarted = true + + onTerminalBackground(hex => { + // xterm.js hosts (VS Code / Cursor) answer OSC 11 with #000000 when the + // editor theme sets no explicit terminal background — the renderer's + // unset DEFAULT, not the painted color (observed: pure black reported on + // a white terminal). A real vscode dark theme reports its actual surface + // (#1e1e1e etc.), so exactly-#000000 from a vscode host carries no + // signal: skip the cache and leave detection to env/config overrides. + if (hex === '#000000' && (process.env.TERM_PROGRAM ?? '') === 'vscode') { + return + } + + process.env.HERMES_TUI_BACKGROUND = hex + reapplyTheme() }) +} const dropBgTask = (taskId: string) => patchUiState(state => { @@ -79,6 +163,8 @@ const normalizeSubagentStatus = (status: unknown, fallback: SubagentStatus): Sub } export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void { + syncThemeToTerminalBackground() + const { rpc } = ctx.gateway const { STARTUP_RESUME_ID, newSession, recoverSidRef, resumeById, setCatalog } = ctx.session const { bellOnComplete, stdout, sys } = ctx.system diff --git a/ui-tui/src/app/slash/commands/debug.ts b/ui-tui/src/app/slash/commands/debug.ts index 868414a66d04..047f9798789b 100644 --- a/ui-tui/src/app/slash/commands/debug.ts +++ b/ui-tui/src/app/slash/commands/debug.ts @@ -1,6 +1,10 @@ +import { terminalBackgroundHex } from '@hermes/ink' + import { formatBytes, performHeapDump } from '../../../lib/memory.js' +import { detectLightMode } from '../../../theme.js' import type { DialogState } from '../../interfaces.js' import { patchOverlayState } from '../../overlayStore.js' +import { getUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' const GRID_TEST_USAGE = 'usage: /grid-test [cols]x[rows] · /grid-test [cols] [rows] · /grid-test streams' @@ -131,6 +135,31 @@ export const debugCommands: SlashCommand[] = [ } }, + { + help: 'print live theme diagnostics (background probe, light mode, palette)', + name: 'theme-info', + run: (_arg, ctx) => { + const { theme } = getUiState() + + ctx.transcript.panel('Theme', [ + { + rows: [ + ['OSC-11 background', terminalBackgroundHex() ?? '(no reply)'], + ['HERMES_TUI_BACKGROUND', process.env.HERMES_TUI_BACKGROUND ?? '(unset)'], + ['HERMES_TUI_THEME', process.env.HERMES_TUI_THEME ?? '(unset)'], + ['COLORFGBG', process.env.COLORFGBG ?? '(unset)'], + ['TERM_PROGRAM', process.env.TERM_PROGRAM ?? '(unset)'], + ['detected mode', detectLightMode() ? 'light' : 'dark'], + ['text', theme.color.text], + ['completionBg', theme.color.completionBg], + ['selectionBg', theme.color.selectionBg], + ['statusBg', theme.color.statusBg] + ] + } + ]) + } + }, + { help: 'print live V8 heap + rss numbers', name: 'mem', diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index e2092a14b745..df3c7b7ffa2e 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -15,6 +15,7 @@ import type { import { formatVoiceRecordKey, parseVoiceRecordKey } from '../../../lib/platform.js' import { fmtK } from '../../../lib/text.js' import type { PanelSection } from '../../../types.js' +import { applyConfiguredTuiTheme } from '../../createGatewayEventHandler.js' import { DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, type IndicatorStyle } from '../../interfaces.js' import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' @@ -414,6 +415,31 @@ export const sessionCommands: SlashCommand[] = [ } }, + { + help: 'pin light/dark mode or trust auto-detection (usage: /theme [auto|light|dark])', + name: 'theme', + usage: '/theme [auto|light|dark]', + run: (arg, ctx) => { + const value = arg.trim().toLowerCase() + + if (!value) { + return ctx.gateway + .rpc('config.get', { key: 'theme' }) + .then(ctx.guarded(r => ctx.transcript.sys(`theme: ${r.value || 'auto'}`))) + } + + if (!['auto', 'light', 'dark'].includes(value)) { + return ctx.transcript.sys('usage: /theme [auto|light|dark]') + } + + // Apply immediately, persist in the background. + applyConfiguredTuiTheme(value) + ctx.gateway + .rpc('config.set', { key: 'theme', value }) + .then(ctx.guarded(r => r.value !== undefined && ctx.transcript.sys(`theme → ${value}`))) + } + }, + { help: 'switch theme skin (fires skin.changed)', name: 'skin', diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 4e08475afab3..074e5b739197 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -7,6 +7,7 @@ import type { ConfigFullResponse, ConfigMtimeResponse, ReloadMcpResponse } from import { DEFAULT_VOICE_RECORD_KEY, type ParsedVoiceRecordKey, parseVoiceRecordKey } from '../lib/platform.js' import { asRpcResult } from '../lib/rpc.js' +import { applyConfiguredTuiTheme } from './createGatewayEventHandler.js' import { type BusyInputMode, DEFAULT_INDICATOR_STYLE, @@ -205,6 +206,8 @@ export const applyDisplay = ( setBell(!!d.bell_on_complete) + applyConfiguredTuiTheme(d.tui_theme) + // Only push the voice record key when the RPC actually returned a // config payload. ``quietRpc()`` collapses failures to ``null``; if we // reset the cached shortcut on every null we would clobber a custom diff --git a/ui-tui/src/components/activeSessionSwitcher.tsx b/ui-tui/src/components/activeSessionSwitcher.tsx index e4ca9ee08509..34e60d7d4f42 100644 --- a/ui-tui/src/components/activeSessionSwitcher.tsx +++ b/ui-tui/src/components/activeSessionSwitcher.tsx @@ -16,6 +16,7 @@ import type { Theme } from '../theme.js' import { ModelPicker } from './modelPicker.js' import { windowOffset } from './overlayControls.js' +import { listRowStyle } from './overlayPrimitives.js' import { TextInput } from './textInput.js' const VISIBLE = 12 @@ -153,9 +154,12 @@ export const orchestratorHintSegmentColor = (t: Theme, role: OrchestratorHintRol return t.color.muted } +// Delegates to the shared list-row primitive so the session switcher and the +// completions popover cannot disagree about what "selected" looks like. +// (`selectionBg` remains the TEXT-selection highlight — a different semantic.) export const selectedSessionRowStyle = (t: Theme) => ({ - backgroundColor: t.color.selectionBg, - color: t.color.text + backgroundColor: listRowStyle(t, true).backgroundColor, + color: listRowStyle(t, true).color }) export const newSessionMarkerColor = (t: Theme, selected: boolean) => diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 3810d5a0da1c..fa3e4952dc3f 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -418,6 +418,7 @@ const ComposerPane = memo(function ComposerPane({ onPaste={composer.handleTextPaste} onSubmit={composer.submit} placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''} + placeholderColor={ui.theme.color.muted} value={composer.input} voiceRecordKey={composer.voiceRecordKey} /> diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 4c117be263b6..60e4bf248584 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -14,6 +14,7 @@ import { GridTestOverlay } from './gridTestOverlay.js' import { MaskedPrompt } from './maskedPrompt.js' import { ModelPicker } from './modelPicker.js' import { OverlayHint } from './overlayControls.js' +import { listRowStyle } from './overlayPrimitives.js' import { PetPicker } from './petPicker.js' import { PluginsHub } from './pluginsHub.js' import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js' @@ -346,13 +347,20 @@ export function FloatingOverlays({ id: 'completions', render: () => ( + {/* No painted panel fill: FloatBox is `opaque`, so rows sit on the + terminal's own background — the one color that is always right + on a canvas we don't own (a full completionBg fill was the lone + surface painting its own background, which is why it could + disagree with every other overlay). Only the ACTIVE row carries + a selection chip, mirroring the session switcher. */} {completions.slice(start, start + viewportSize).map((item, i) => { const active = start + i === compIdx + const row = listRowStyle(theme, active) return ( {item.meta ? ( - + {' '} {item.meta} diff --git a/ui-tui/src/components/overlayPrimitives.tsx b/ui-tui/src/components/overlayPrimitives.tsx index 995a4d85c207..d24fa9fc0e74 100644 --- a/ui-tui/src/components/overlayPrimitives.tsx +++ b/ui-tui/src/components/overlayPrimitives.tsx @@ -51,11 +51,28 @@ export function useMenu(rows: MenuRowSpec[], onEscape: () => void, onKey?: (ch: return Math.min(sel, Math.max(0, rows.length - 1)) } -/** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). */ +/** + * THE selected-row treatment for every list surface (completions popover, + * session switcher, pickers): a `selection` chip on the active row, nothing + * painted otherwise. Panels never paint their own full background — floating + * surfaces are `opaque` (terminal-native canvas) and only the active row + * carries color, so list surfaces cannot disagree about "selected" and stay + * correct on any terminal background. Callers own layout; this owns color. + */ +export function listRowStyle(t: Theme, active: boolean): { backgroundColor?: string; color?: string } { + return active ? { backgroundColor: t.color.completionCurrentBg, color: t.color.text } : {} +} + +/** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). Active rows + * carry the shared list-row selection chip — same treatment as completions + * and the session switcher — instead of `inverse`, whose contrast depends on + * the terminal's unknowable default colors. */ export function MenuRow({ active, index, label, t }: { active: boolean; index: number; label: string; t: Theme }) { + const row = listRowStyle(t, active) + return ( - + {active ? '▸ ' : ' '} {index}. {label} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 9fed82096d25..3ac29d368edd 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -43,6 +43,21 @@ type MinimalEnv = Record const invert = (s: string) => INV + s + INV_OFF const dim = (s: string) => DIM + s + DIM_OFF +/** Truecolor foreground wrap; falls back to SGR dim when no hex is given so + * the placeholder can follow the THEME's muted color instead of whatever the + * terminal's default-foreground dim happens to look like. */ +const colorizeHint = (s: string, hex?: string) => { + const m = hex ? /^#([0-9a-f]{6})$/i.exec(hex) : null + + if (!m) { + return dim(s) + } + + const n = parseInt(m[1]!, 16) + + return `${ESC}[38;2;${(n >> 16) & 0xff};${(n >> 8) & 0xff};${n & 0xff}m${s}${ESC}[39m` +} + let _seg: Intl.Segmenter | null = null const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' })) const STOP_CACHE_MAX = 32 @@ -540,6 +555,7 @@ export function TextInput({ mouseApiRef, voiceRecordKey = DEFAULT_VOICE_RECORD_KEY, placeholder = '', + placeholderColor, focus = true }: TextInputProps) { const [cur, setCur] = useState(value.length) @@ -620,17 +636,20 @@ export function TextInput({ const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY - // Placeholder text is just a hint, not a selection — render it dim - // without inverse styling. In a TTY the hardware cursor parks at column - // 0 and visually marks the input start. Non-TTY surfaces still need the - // synthetic inverse first-char to draw a cursor at all. + // Placeholder text is just a hint, not a selection — render it in the + // theme's muted color (SGR dim as fallback) without inverse styling. In a + // TTY the hardware cursor parks at column 0 and visually marks the input + // start. Non-TTY surfaces still need the synthetic inverse first-char to + // draw a cursor at all. const rendered = useMemo(() => { if (!focus) { - return display || dim(placeholder) + return display || colorizeHint(placeholder, placeholderColor) } if (!display && placeholder) { - return nativeCursor ? dim(placeholder) : invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1)) + return nativeCursor + ? colorizeHint(placeholder, placeholderColor) + : invert(placeholder[0] ?? ' ') + colorizeHint(placeholder.slice(1), placeholderColor) } if (selected) { @@ -638,7 +657,7 @@ export function TextInput({ } return nativeCursor ? display || ' ' : renderWithCursor(display, cur) - }, [cur, display, focus, nativeCursor, placeholder, selected]) + }, [cur, display, focus, nativeCursor, placeholder, placeholderColor, selected]) useEffect(() => { if (self.current) { @@ -1387,6 +1406,8 @@ interface TextInputProps { ) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null onSubmit?: (v: string) => void placeholder?: string + /** Hex color for placeholder text (theme muted); SGR dim when omitted. */ + placeholderColor?: string value: string voiceRecordKey?: ParsedVoiceRecordKey } diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index f6b3787a10de..521161840618 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -7,7 +7,11 @@ export interface GatewaySkin { banner_logo?: string branding?: Record colors?: Record + /** Hand-tuned palette for dark terminals (light-authored skins). */ + dark_colors?: Record help_header?: string + /** Hand-tuned palette for light terminals (dark-authored skins). */ + light_colors?: Record tool_prefix?: string } @@ -103,6 +107,9 @@ export interface ConfigDisplayConfig { // validation anyway. tui_status_indicator?: string tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean + /** Theme mode pin: 'light' / 'dark' beat background auto-detection; 'auto' + * (default) trusts the OSC-11 probe + env signals. */ + tui_theme?: string } export interface ConfigVoiceConfig { diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 8c604df8a0f6..850fcca4c7ca 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -158,6 +158,12 @@ function circularDistance(a: number, b: number): number { return Math.min(distance, 1 - distance) } +function hexLuminance(color: string): null | number { + const rgb = parseHex(color) + + return rgb ? relativeLuminance(rgb[0], rgb[1], rgb[2]) : null +} + // Mirrors @hermes/ink's colorize.ts. Keep local: app code compiles from // ui-tui/src, while @hermes/ink is bundled separately from packages/. function richEightBitColorNumber(red: number, green: number, blue: number): number { @@ -346,6 +352,147 @@ export const LIGHT_THEME: Theme = { bannerHero: '' } +// ── Background-aware readability adaptation ───────────────────────── +// +// Mirrors the desktop app's theme contract (apps/desktop/src/themes): skins +// contribute accent IDENTITY; readability against the actual background is +// the theme engine's job, enforced in one place. Two guards, in the desktop's +// vocabulary: +// +// * `ensureContrast` — foreground-role colors are step-mixed toward the +// readable pole (black on light, white on dark) until they clear a +// minimum WCAG contrast ratio against the real (or assumed) background. +// Hue survives; washout doesn't. +// * Fill polarity — background-role colors (completion menu, status bar, +// selection) must match the terminal's polarity. Unlike the desktop, the +// TUI does not own its canvas — panels sit directly on the terminal's +// background — so a wrong-polarity fill (navy menu on a white terminal) +// falls back to the base palette even when the skin authored it. + +/** WCAG contrast ratio between two hex colors (1–21). Null if unparseable. */ +export function contrastRatio(a: string, b: string): null | number { + const la = hexLuminance(a) + const lb = hexLuminance(b) + + if (la === null || lb === null) { + return null + } + + const [hi, lo] = la >= lb ? [la, lb] : [lb, la] + + return (hi + 0.05) / (lo + 0.05) +} + +/** + * Step-mix `color` toward the readable pole of `bg` until the contrast + * ratio clears `min` (desktop `color.ts::ensureContrast`). Returns the + * original when it already passes or isn't hex. + */ +export function ensureContrast(color: string, bg: string, min: number): string { + const bgLuminance = hexLuminance(bg) + + if (bgLuminance === null || parseHex(color) === null) { + return color + } + + const pole = bgLuminance > 0.5 ? '#000000' : '#ffffff' + let current = color + + for (let step = 0; step <= 20; step++) { + const ratio = contrastRatio(current, bg) + + if (ratio === null || ratio >= min) { + return current + } + + current = mix(color, pole, Math.min(1, (step + 1) * 0.05)) + } + + return current +} + +// Contrast tiers, tuned so both base palettes are fixed points: primary +// text/labels/status readable at ≥ 3.9; muted/secondary/ambers at ≥ 2.8. +const STRONG_MIN_CONTRAST = 3.9 +const SOFT_MIN_CONTRAST = 2.8 + +const STRONG_FOREGROUNDS: readonly (keyof ThemeColors)[] = [ + 'primary', + 'accent', + 'text', + 'label', + 'ok', + 'error', + 'prompt', + 'statusFg', + 'statusGood', + 'statusBad', + 'statusCritical', + 'shellDollar' +] + +const SOFT_FOREGROUNDS: readonly (keyof ThemeColors)[] = [ + 'border', + 'muted', + 'warn', + 'sessionLabel', + 'sessionBorder', + 'statusWarn' +] + +const ADAPTIVE_BACKGROUNDS: readonly (keyof ThemeColors)[] = [ + 'completionBg', + 'completionCurrentBg', + 'completionMetaBg', + 'completionMetaCurrentBg', + 'statusBg', + 'selectionBg' +] + +// Fill polarity limits: on light terminals a fill must stay light, and vice +// versa — there is no readable middle for a panel fill on the wrong pole. +const LIGHT_BG_MIN_LUMINANCE = 0.4 +const DARK_BG_MAX_LUMINANCE = 0.35 + +function adaptColorsToBackground(colors: ThemeColors, isLight: boolean, base: ThemeColors, bg: string): ThemeColors { + const out = { ...colors } + + for (const key of STRONG_FOREGROUNDS) { + out[key] = ensureContrast(out[key], bg, STRONG_MIN_CONTRAST) + } + + for (const key of SOFT_FOREGROUNDS) { + out[key] = ensureContrast(out[key], bg, SOFT_MIN_CONTRAST) + } + + for (const key of ADAPTIVE_BACKGROUNDS) { + const luminance = hexLuminance(out[key]) + + if (luminance === null) { + continue + } + + if (isLight ? luminance < LIGHT_BG_MIN_LUMINANCE : luminance > DARK_BG_MAX_LUMINANCE) { + out[key] = base[key] + } + } + + return out +} + +/** The background hex adaptation measures contrast against: the OSC-11 + * answer when known (cached in HERMES_TUI_BACKGROUND), else the mode's + * assumed pole. */ +function referenceBackground(isLight: boolean, env: NodeJS.ProcessEnv = process.env): string { + const cached = (env.HERMES_TUI_BACKGROUND ?? '').trim() + + if (cached && backgroundLuminance(cached) !== null) { + return cached.startsWith('#') ? cached : `#${cached}` + } + + return isLight ? '#ffffff' : '#101014' +} + const TRUE_RE = /^(?:1|true|yes|on)$/ const FALSE_RE = /^(?:0|false|no|off)$/ @@ -508,6 +655,19 @@ export const DEFAULT_THEME: Theme = normalizeThemeForAnsiLightTerminal( DEFAULT_LIGHT_MODE ) +/** + * The skinless theme for the CURRENT light-mode signals. Unlike the frozen + * module-load DEFAULT_THEME, this re-reads the environment — so it picks up + * the OSC-11 background answer cached into HERMES_TUI_BACKGROUND after + * startup. Used when the terminal background arrives before (or without) a + * gateway skin. + */ +export function defaultThemeForCurrentBackground(env: NodeJS.ProcessEnv = process.env): Theme { + const isLight = detectLightMode(env) + + return normalizeThemeForAnsiLightTerminal(isLight ? LIGHT_THEME : DARK_THEME, env, isLight) +} + // ── Skin → Theme ───────────────────────────────────────────────────── export function fromSkin( @@ -518,7 +678,13 @@ export function fromSkin( toolPrefix = '', helpHeader = '' ): Theme { - const d = DEFAULT_THEME + // Live detection (not the module-load snapshot): by the time the gateway + // skin arrives, the OSC-11 background probe has usually answered and cached + // itself into HERMES_TUI_BACKGROUND, so the fallback base — every color key + // the skin does NOT define — matches the actual terminal background instead + // of assuming dark. See #applySkin / syncThemeToTerminalBackground. + const isLight = detectLightMode() + const d = isLight ? LIGHT_THEME : DARK_THEME const c = (k: string) => colors[k] const hasSkinColors = Object.keys(colors).length > 0 @@ -534,45 +700,54 @@ export function fromSkin( const completionMetaBg = c('completion_menu_meta_bg') ?? completionBg const completionMetaCurrentBg = c('completion_menu_meta_current_bg') ?? completionCurrentBg + const assembled: ThemeColors = { + primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary, + accent, + border: c('ui_border') ?? c('banner_border') ?? d.color.border, + text: c('ui_text') ?? c('banner_text') ?? d.color.text, + muted, + completionBg, + completionCurrentBg, + completionMetaBg, + completionMetaCurrentBg, + + label: c('ui_label') ?? d.color.label, + ok: c('ui_ok') ?? d.color.ok, + error: c('ui_error') ?? d.color.error, + warn: c('ui_warn') ?? d.color.warn, + + prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt, + sessionLabel: c('session_label') ?? muted, + sessionBorder: c('session_border') ?? muted, + + statusBg: c('status_bar_bg') ?? d.color.statusBg, + statusFg: c('status_bar_text') ?? d.color.statusFg, + statusGood: c('status_bar_good') ?? c('ui_ok') ?? d.color.statusGood, + statusWarn: c('status_bar_warn') ?? c('ui_warn') ?? d.color.statusWarn, + statusBad: c('status_bar_bad') ?? d.color.statusBad, + statusCritical: c('status_bar_critical') ?? d.color.statusCritical, + selectionBg: + c('selection_bg') ?? + c('completion_menu_current_bg') ?? + (hasSkinColors ? completionCurrentBg : d.color.selectionBg), + + diffAdded: d.color.diffAdded, + diffRemoved: d.color.diffRemoved, + diffAddedWord: d.color.diffAddedWord, + diffRemovedWord: d.color.diffRemovedWord, + shellDollar: c('shell_dollar') ?? d.color.shellDollar + } + + // Two exclusive readability strategies: ANSI-limited light Apple Terminal + // keeps its bespoke ansi256 normalization (below) byte-for-byte; every + // truecolor terminal gets contrast enforcement against the real background. + const adapted = shouldNormalizeAnsiLightTheme(process.env, isLight) + ? assembled + : adaptColorsToBackground(assembled, isLight, d.color, referenceBackground(isLight)) + return normalizeThemeForAnsiLightTerminal( { - color: { - primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary, - accent, - border: c('ui_border') ?? c('banner_border') ?? d.color.border, - text: c('ui_text') ?? c('banner_text') ?? d.color.text, - muted, - completionBg, - completionCurrentBg, - completionMetaBg, - completionMetaCurrentBg, - - label: c('ui_label') ?? d.color.label, - ok: c('ui_ok') ?? d.color.ok, - error: c('ui_error') ?? d.color.error, - warn: c('ui_warn') ?? d.color.warn, - - prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt, - sessionLabel: c('session_label') ?? muted, - sessionBorder: c('session_border') ?? muted, - - statusBg: d.color.statusBg, - statusFg: d.color.statusFg, - statusGood: c('ui_ok') ?? d.color.statusGood, - statusWarn: c('ui_warn') ?? d.color.statusWarn, - statusBad: d.color.statusBad, - statusCritical: d.color.statusCritical, - selectionBg: - c('selection_bg') ?? - c('completion_menu_current_bg') ?? - (hasSkinColors ? completionCurrentBg : d.color.selectionBg), - - diffAdded: d.color.diffAdded, - diffRemoved: d.color.diffRemoved, - diffAddedWord: d.color.diffAddedWord, - diffRemovedWord: d.color.diffRemovedWord, - shellDollar: c('shell_dollar') ?? d.color.shellDollar - }, + color: adapted, brand: { name: branding.agent_name ?? d.brand.name, @@ -588,6 +763,6 @@ export function fromSkin( bannerHero }, process.env, - DEFAULT_LIGHT_MODE + isLight ) } diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 034e04cf6da1..ea73d338d421 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -107,6 +107,9 @@ declare module '@hermes/ink' { export const TextInput: React.ComponentType export const stringWidth: (s: string) => number export function isXtermJs(): boolean + export function onTerminalBackground(listener: (hex: string) => void): void + export function terminalBackgroundHex(): string | undefined + export function parseOscColor(data: string): string | undefined export type ScrollFastPathStats = { captured: number From c0763bd13a18f27c1f63b820b8384521b1c20828 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:36:42 -0500 Subject: [PATCH 04/22] =?UTF-8?q?feat(ui-tui):=20theme=20engine=20?= =?UTF-8?q?=E2=80=94=20seeds=20to=20derived-tone=20ladder=20+=20flash-free?= =?UTF-8?q?=20boot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop color-mix system, ported: a theme is a handful of identity SEEDS (text, primary, accent, border, status hues); every secondary tone (muted, label, surfaces, chips, selection) is a color-mix derivative against the real terminal background. lib/color.ts consolidates the primitives (parse/mix/luminance/contrast/retone + xterm.js's multiplicative liftForContrast). Knobs are grid-search fitted so the math reproduces the classic hand-tuned literals (contract-tested). The display shim renders authored palettes RAW and only rescues near-invisible colors. Boot reads the last resolved theme from $HERMES_HOME/tui-theme-boot.json so the first frame paints in the right palette (no default-dark flash), and the placeholder cursor follows the bubbles textinput pattern. --- hermes_cli/skin_engine.py | 240 --------- tests/hermes_cli/test_skin_palettes.py | 37 +- ui-tui/package.json | 1 + ui-tui/scripts/visual/render.tsx | 309 +++++++++++ ui-tui/scripts/visual/shot.mjs | 23 + ui-tui/src/__tests__/theme.test.ts | 141 ++++- ui-tui/src/app/createGatewayEventHandler.ts | 53 +- ui-tui/src/app/uiStore.ts | 5 +- ui-tui/src/components/appOverlays.tsx | 4 +- ui-tui/src/components/overlayPrimitives.tsx | 12 +- ui-tui/src/components/textInput.tsx | 34 +- ui-tui/src/lib/color.test.ts | 66 +++ ui-tui/src/lib/color.ts | 307 +++++++++++ ui-tui/src/lib/themeBoot.ts | 119 +++++ ui-tui/src/theme.ts | 555 +++++++++++--------- 15 files changed, 1362 insertions(+), 544 deletions(-) create mode 100644 ui-tui/scripts/visual/render.tsx create mode 100644 ui-tui/scripts/visual/shot.mjs create mode 100644 ui-tui/src/lib/color.test.ts create mode 100644 ui-tui/src/lib/color.ts create mode 100644 ui-tui/src/lib/themeBoot.ts diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 58a22ae14a74..679416b8971d 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -292,36 +292,6 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "shell_dollar": "#DD4A3A", "voice_status_bg": "#2A1212", }, - "light_colors": { - "banner_border": "#A93333", - "banner_title": "#8B764B", - "banner_accent": "#D24637", - "banner_dim": "#905151", - "banner_text": "#3C3A34", - "ui_accent": "#D24637", - "ui_label": "#8B764B", - "ui_ok": "#39833C", - "ui_error": "#CB4744", - "ui_warn": "#BF7D1C", - "prompt": "#3C3A34", - "input_rule": "#A93333", - "response_border": "#9F8756", - "status_bar_bg": "#F5EEEF", - "status_bar_text": "#33373D", - "status_bar_strong": "#847047", - "status_bar_dim": "#8A8F98", - "status_bar_good": "#508348", - "status_bar_warn": "#9F8756", - "status_bar_bad": "#D24637", - "status_bar_critical": "#CB4744", - "session_label": "#9F8756", - "session_border": "#6E584B", - "completion_menu_bg": "#F5EEEF", - "completion_menu_current_bg": "#F0CAC7", - "selection_bg": "#F8DBD8", - "shell_dollar": "#D24637", - "voice_status_bg": "#F5EEEF", - }, "spinner": { "waiting_faces": ["(⚔)", "(⛨)", "(▲)", "(<>)", "(/)"], "thinking_faces": ["(⚔)", "(⛨)", "(▲)", "(⌁)", "(<>)"], @@ -399,36 +369,6 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "shell_dollar": "#aaaaaa", "voice_status_bg": "#1F1F1F", }, - "light_colors": { - "banner_border": "#5E5E5E", - "banner_title": "#73767A", - "banner_accent": "#777777", - "banner_dim": "#606060", - "banner_text": "#323436", - "ui_accent": "#777777", - "ui_label": "#7A7A7A", - "ui_ok": "#7A7A7A", - "ui_error": "#7A7A7A", - "ui_warn": "#919191", - "prompt": "#323436", - "input_rule": "#606060", - "response_border": "#909090", - "status_bar_bg": "#F2F3F5", - "status_bar_text": "#33373D", - "status_bar_strong": "#73767A", - "status_bar_dim": "#8A8F98", - "status_bar_good": "#767676", - "status_bar_warn": "#909090", - "status_bar_bad": "#727272", - "status_bar_critical": "#787878", - "session_label": "#888888", - "session_border": "#5E5E5E", - "completion_menu_bg": "#F2F3F5", - "completion_menu_current_bg": "#E2E3E4", - "selection_bg": "#EEEEEE", - "shell_dollar": "#777777", - "voice_status_bg": "#F2F3F5", - }, "spinner": {}, "branding": { "agent_name": "Hermes Agent", @@ -473,36 +413,6 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "shell_dollar": "#7eb8f6", "voice_status_bg": "#151C2F", }, - "light_colors": { - "banner_border": "#4169e1", - "banner_title": "#5278A0", - "banner_accent": "#6376B2", - "banner_dim": "#545E6B", - "banner_text": "#323436", - "ui_accent": "#5278A0", - "ui_label": "#6376B2", - "ui_ok": "#40876C", - "ui_error": "#A1684A", - "ui_warn": "#B88644", - "prompt": "#323436", - "input_rule": "#4169e1", - "response_border": "#6593C5", - "status_bar_bg": "#F0F4F9", - "status_bar_text": "#33373D", - "status_bar_strong": "#5278A0", - "status_bar_dim": "#8A8F98", - "status_bar_good": "#40876C", - "status_bar_warn": "#B88644", - "status_bar_bad": "#A1684A", - "status_bar_critical": "#BF5C5C", - "session_label": "#6593C5", - "session_border": "#545E6B", - "completion_menu_bg": "#F0F4F9", - "completion_menu_current_bg": "#C3DAF3", - "selection_bg": "#E5F1FD", - "shell_dollar": "#5278A0", - "voice_status_bg": "#F0F4F9", - }, "spinner": {}, "branding": { "agent_name": "Hermes Agent", @@ -549,36 +459,6 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "shell_dollar": "#2563EB", "voice_status_bg": "#E5EDF8", }, - "dark_colors": { - "banner_border": "#2563EB", - "banner_title": "#767B85", - "banner_accent": "#4A71E0", - "banner_dim": "#596677", - "banner_text": "#DBDCDE", - "ui_accent": "#3A72ED", - "ui_label": "#328A83", - "ui_ok": "#2C8C50", - "ui_error": "#CA5252", - "ui_warn": "#B45309", - "prompt": "#DBDCDE", - "input_rule": "#6E94BE", - "response_border": "#2563EB", - "status_bar_bg": "#161C2A", - "status_bar_text": "#D8DADC", - "status_bar_strong": "#5C8BF2", - "status_bar_dim": "#838890", - "status_bar_good": "#2C8C50", - "status_bar_warn": "#B45309", - "status_bar_bad": "#BC6421", - "status_bar_critical": "#CA5252", - "session_label": "#3460DC", - "session_border": "#64748B", - "completion_menu_bg": "#161C2A", - "completion_menu_current_bg": "#1A2E5A", - "selection_bg": "#1A3060", - "shell_dollar": "#3A72ED", - "voice_status_bg": "#161C2A", - }, "spinner": {}, "branding": { "agent_name": "Hermes Agent", @@ -625,36 +505,6 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "shell_dollar": "#8B4513", "voice_status_bg": "#F5F0E8", }, - "dark_colors": { - "banner_border": "#8B6914", - "banner_title": "#8B7555", - "banner_accent": "#A7714B", - "banner_dim": "#8B7355", - "banner_text": "#DFDCDB", - "ui_accent": "#A7714B", - "ui_label": "#8B7555", - "ui_ok": "#428A46", - "ui_error": "#D15151", - "ui_warn": "#E65100", - "prompt": "#DFDCDB", - "input_rule": "#8B6914", - "response_border": "#8B6914", - "status_bar_bg": "#1C1B1D", - "status_bar_text": "#DAD6D5", - "status_bar_strong": "#A7714B", - "status_bar_dim": "#8A8F98", - "status_bar_good": "#428A46", - "status_bar_warn": "#E65100", - "status_bar_bad": "#DA4D00", - "status_bar_critical": "#D15151", - "session_label": "#7B623F", - "session_border": "#A0845C", - "completion_menu_bg": "#1C1B1D", - "completion_menu_current_bg": "#38261A", - "selection_bg": "#3B261A", - "shell_dollar": "#A7714B", - "voice_status_bg": "#1C1B1D", - }, "spinner": {}, "branding": { "agent_name": "Hermes Agent", @@ -699,36 +549,6 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "shell_dollar": "#5DB8F5", "voice_status_bg": "#0F2440", }, - "light_colors": { - "banner_border": "#2A6FB9", - "banner_title": "#5D7B8C", - "banner_accent": "#3C789F", - "banner_dim": "#44638F", - "banner_text": "#3A3E40", - "ui_accent": "#3C789F", - "ui_label": "#5D7B8C", - "ui_ok": "#39833C", - "ui_error": "#CB4744", - "ui_warn": "#BF7D1C", - "prompt": "#3A3E40", - "input_rule": "#2A6FB9", - "response_border": "#4A93C4", - "status_bar_bg": "#EEF4F9", - "status_bar_text": "#33373D", - "status_bar_strong": "#5D7B8C", - "status_bar_dim": "#8A8F98", - "status_bar_good": "#42816A", - "status_bar_warn": "#4A93C4", - "status_bar_bad": "#3576BC", - "status_bar_critical": "#CE4B4B", - "session_label": "#6E91A6", - "session_border": "#496884", - "completion_menu_bg": "#EEF4F9", - "completion_menu_current_bg": "#CEE7F8", - "selection_bg": "#DFF1FD", - "shell_dollar": "#3C789F", - "voice_status_bg": "#EEF4F9", - }, "spinner": { "waiting_faces": ["(≈)", "(Ψ)", "(∿)", "(◌)", "(◠)"], "thinking_faces": ["(Ψ)", "(∿)", "(≈)", "(⌁)", "(◌)"], @@ -806,36 +626,6 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "shell_dollar": "#E7E7E7", "voice_status_bg": "#202020", }, - "light_colors": { - "banner_border": "#898989", - "banner_title": "#7A7A7A", - "banner_accent": "#747474", - "banner_dim": "#5C5C5C", - "banner_text": "#353535", - "ui_accent": "#747474", - "ui_label": "#747474", - "ui_ok": "#747474", - "ui_error": "#747474", - "ui_warn": "#898989", - "prompt": "#3D3D3D", - "input_rule": "#656565", - "response_border": "#898989", - "status_bar_bg": "#F5F6F8", - "status_bar_text": "#33373D", - "status_bar_strong": "#7A7A7A", - "status_bar_dim": "#8A8F98", - "status_bar_good": "#777777", - "status_bar_warn": "#898989", - "status_bar_bad": "#747474", - "status_bar_critical": "#7A7A7A", - "session_label": "#919191", - "session_border": "#656565", - "completion_menu_bg": "#F5F6F8", - "completion_menu_current_bg": "#D9DBDE", - "selection_bg": "#FAFAFA", - "shell_dollar": "#747474", - "voice_status_bg": "#F5F6F8", - }, "spinner": { "waiting_faces": ["(◉)", "(◌)", "(◬)", "(⬤)", "(::)"], "thinking_faces": ["(◉)", "(◬)", "(◌)", "(○)", "(●)"], @@ -916,36 +706,6 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "shell_dollar": "#F29C38", "voice_status_bg": "#2B160E", }, - "light_colors": { - "banner_border": "#C75B1D", - "banner_title": "#8C7455", - "banner_accent": "#A96D27", - "banner_dim": "#BB8342", - "banner_text": "#403C35", - "ui_accent": "#A96D27", - "ui_label": "#8C7455", - "ui_ok": "#39833C", - "ui_error": "#CB4744", - "ui_warn": "#BF7D1C", - "prompt": "#403C35", - "input_rule": "#C75B1D", - "response_border": "#C27D2D", - "status_bar_bg": "#F6F2EF", - "status_bar_text": "#33373D", - "status_bar_strong": "#8C7455", - "status_bar_dim": "#8A8F98", - "status_bar_good": "#46844D", - "status_bar_warn": "#C27D2D", - "status_bar_bad": "#AA6220", - "status_bar_critical": "#CB4744", - "session_label": "#A68964", - "session_border": "#7B593A", - "completion_menu_bg": "#F6F2EF", - "completion_menu_current_bg": "#F5DFC7", - "selection_bg": "#FCEBD7", - "shell_dollar": "#A96D27", - "voice_status_bg": "#F6F2EF", - }, "spinner": { "waiting_faces": ["(✦)", "(▲)", "(◇)", "(<>)", "(🔥)"], "thinking_faces": ["(✦)", "(▲)", "(◇)", "(⌁)", "(🔥)"], diff --git a/tests/hermes_cli/test_skin_palettes.py b/tests/hermes_cli/test_skin_palettes.py index 90290e38850d..519cc8de65c4 100644 --- a/tests/hermes_cli/test_skin_palettes.py +++ b/tests/hermes_cli/test_skin_palettes.py @@ -117,39 +117,30 @@ def contrast(a: str, b: str) -> float: return (hi + 0.05) / (lo + 0.05) +# Light-authored built-ins (everything else is dark-authored). Paired +# palettes are OPTIONAL, hand-authored refinements: cross-polarity rendering +# is handled by the TUI's display shim (xterm-style hue-preserving contrast +# lift), which reproduces exactly what minimumContrastRatio hosts display — +# the look the maintainers standardized on. Generated paired palettes are +# explicitly NOT wanted; only ship one when a human tuned it. +LIGHT_AUTHORED = frozenset({"daylight", "warm-lightmode"}) + + def _palettes(): """Yield (skin, palette_name, palette, is_light) for every built-in.""" for name, skin in _BUILTIN_SKINS.items(): - colors = skin.get("colors", {}) - light = skin.get("light_colors", {}) - dark = skin.get("dark_colors", {}) + yield name, "colors", skin.get("colors", {}), name in LIGHT_AUTHORED - # `colors` polarity is declared by which paired block the skin ships. - colors_are_light = bool(dark) and not light - yield name, "colors", colors, colors_are_light - - if light: - yield name, "light_colors", light, True - if dark: - yield name, "dark_colors", dark, False + if skin.get("light_colors"): + yield name, "light_colors", skin["light_colors"], True + if skin.get("dark_colors"): + yield name, "dark_colors", skin["dark_colors"], False ALL_PALETTES = list(_palettes()) PALETTE_IDS = [f"{skin}:{block}" for skin, block, _, _ in ALL_PALETTES] -def test_every_builtin_ships_a_paired_palette(): - for name, skin in _BUILTIN_SKINS.items(): - assert skin.get("light_colors") or skin.get("dark_colors"), ( - f"skin {name!r} has no paired palette: dark-authored skins need " - f"light_colors, light-authored skins need dark_colors" - ) - assert not (skin.get("light_colors") and skin.get("dark_colors")), ( - f"skin {name!r} declares both paired blocks; `colors` polarity " - f"would be ambiguous" - ) - - @pytest.mark.parametrize(("skin", "block", "palette", "is_light"), ALL_PALETTES, ids=PALETTE_IDS) def test_palette_is_complete(skin, block, palette, is_light): missing = REQUIRED_KEYS - palette.keys() diff --git a/ui-tui/package.json b/ui-tui/package.json index 34fa3c31db4f..105dba69f86b 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -8,6 +8,7 @@ "start": "tsx src/entry.tsx", "build": "node scripts/build.mjs", "build:ink": "npm run build --prefix packages/hermes-ink", + "visual": "FORCE_COLOR=3 COLORTERM=truecolor tsx scripts/visual/render.tsx && electron scripts/visual/shot.mjs", "typecheck": "tsc --noEmit -p tsconfig.json", "lint": "eslint src/ packages/", "lint:fix": "eslint src/ packages/ --fix", diff --git a/ui-tui/scripts/visual/render.tsx b/ui-tui/scripts/visual/render.tsx new file mode 100644 index 000000000000..e89887f5771c --- /dev/null +++ b/ui-tui/scripts/visual/render.tsx @@ -0,0 +1,309 @@ +/* Visual self-verification tool: `npm run visual` renders real TUI surfaces + * across theme x background scenes to /tmp/tui-visual.html, then shot.mjs + * screenshots it to /tmp/tui-visual.png for eyeball + agent review. + * + * Original note: : render real TUI surfaces with ANSI colors intact, + * convert to HTML on the actual background, and screenshot in a browser. */ +process.env.FORCE_COLOR = '3' +process.env.COLORTERM = 'truecolor' + +import '../../src/lib/forceTruecolor.js' + +import { writeFileSync } from 'fs' +import { PassThrough } from 'stream' + +import { Box, renderSync, Text } from '@hermes/ink' +import React, { type ReactElement } from 'react' + +import { GatewayProvider } from '../../src/app/gatewayContext.js' +import { patchOverlayState, resetOverlayState } from '../../src/app/overlayStore.js' +import { patchUiState, resetUiState } from '../../src/app/uiStore.js' +import { FloatingOverlays } from '../../src/components/appOverlays.js' +import { Banner, SessionPanel } from '../../src/components/branding.js' +import { fromSkin, type Theme } from '../../src/theme.js' +import type { SessionInfo } from '../../src/types.js' + +const noop = () => {} +const pending = () => new Promise(() => {}) + +const fakeGateway = { gw: { notify: noop, off: noop, on: noop, request: pending }, rpc: pending } as any + +const SLATE = { + banner_border: '#4169e1', + banner_title: '#7eb8f6', + banner_accent: '#8EA8FF', + banner_dim: '#4b5563', + banner_text: '#c9d1d9', + ui_accent: '#7eb8f6', + ui_label: '#8EA8FF', + ui_ok: '#63D0A6', + ui_error: '#F7A072', + ui_warn: '#e6a855', + prompt: '#c9d1d9', + session_label: '#7eb8f6', + session_border: '#545E6B', + status_bar_bg: '#151C2F', + status_bar_text: '#C9D1D9' +} + +// The regenerated slate light_colors block from hermes_cli/skin_engine.py +// (relight recipe: vivid hue-preserved accents, airy capped-saturation text, +// darker calm dims). + +const info: SessionInfo = { + cwd: '/Users/brooklyn/www/hermes-agent', + mcp_servers: [{ connected: true, name: 'figma', tools: 12, transport: 'sse' }], + model: 'claude-opus-4.8-fast', + skills: { + devops: ['docker', 'kubernetes', 'terraform'], + github: ['pr-review', 'issue-triage'], + productivity: ['powerpoint', 'excel', 'notion-sync'] + }, + tools: { + browser: ['browser_back', 'browser_click', 'browser_console', 'browser_get_images'], + clarify: ['clarify'], + code_execution: ['execute_code'], + cronjob: ['cronjob'], + delegation: ['delegate_task'], + file: ['patch', 'read_file', 'search_files', 'write_file'] + }, + update_behind: 1, + version: '3.2.1' +} + +const completions = [ + { display: '/new', meta: 'Start a new session (fresh session ID + history)', text: '/new' }, + { display: '/reset', meta: 'Start a new session (alias for /new)', text: '/reset' }, + { display: '/clear', meta: 'Clear screen and start a new session', text: '/clear' }, + { display: '/redraw', meta: 'Force a full UI repaint', text: '/redraw' }, + { display: '/history', meta: 'Show conversation history', text: '/history' } +] + +function renderAnsi(node: ReactElement, columns: number): string { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + + let output = '' + + ;(process.stdout as unknown as { columns: number }).columns = columns + Object.assign(stdout, { columns, isTTY: false, rows: 60 }) + Object.assign(stdin, { + isTTY: true, + pause: noop, + ref: noop, + resume: noop, + setEncoding: noop, + setRawMode: noop, + unref: noop + }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const instance = renderSync( + + {node} + , + { + exitOnCtrlC: false, + patchConsole: false, + stderr: stderr as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream + } + ) + + instance.unmount() + instance.cleanup() + + return output +} + +// ── ANSI → HTML (handles ink's SGR set: 38;2/48;2 truecolor, named resets, bold/dim/italic/inverse) ── + +const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(//g, '>') + +function ansiToHtml(raw: string, defaultFg: string, defaultBg: string): string { + let fg = defaultFg + let bg = defaultBg + let bold = false + let dim = false + let italic = false + let inverse = false + let html = '' + + const openSpan = () => { + const f = inverse ? bg : fg + const b = inverse ? fg : bg + const styles = [`color:${f}`] + + if (b !== defaultBg || inverse) { + styles.push(`background-color:${b}`) + } + + if (bold) { + styles.push('font-weight:bold') + } + + if (dim) { + styles.push('opacity:0.55') + } + + if (italic) { + styles.push('font-style:italic') + } + + return `` + } + + // eslint-disable-next-line no-control-regex + const parts = raw.split(/(\x1b\[[0-9;]*m)/) + + html += openSpan() + + for (const part of parts) { + // eslint-disable-next-line no-control-regex + const m = /^\x1b\[([0-9;]*)m$/.exec(part) + + if (!m) { + // Drop non-SGR escapes (cursor moves etc.) — renderSync output for a + // static frame is line-oriented, so this is safe for inspection. + // eslint-disable-next-line no-control-regex + html += escapeHtml(part.replace(/\x1b\[[^m]*[A-Za-z]/g, '')) + + continue + } + + const codes = (m[1] || '0').split(';').map(Number) + + for (let i = 0; i < codes.length; i++) { + const c = codes[i]! + + if (c === 0) { + fg = defaultFg + bg = defaultBg + bold = dim = italic = inverse = false + } else if (c === 1) {bold = true} + else if (c === 2) {dim = true} + else if (c === 3) {italic = true} + else if (c === 7) {inverse = true} + else if (c === 22) { bold = false; dim = false } + else if (c === 23) {italic = false} + else if (c === 27) {inverse = false} + else if (c === 39) {fg = defaultFg} + else if (c === 49) {bg = defaultBg} + else if (c === 38 && codes[i + 1] === 2) { + fg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})` + i += 4 + } else if (c === 48 && codes[i + 1] === 2) { + bg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})` + i += 4 + } else if (c === 38 && codes[i + 1] === 5) { + fg = `var(--a${codes[i + 2]}, #888)` + i += 2 + } else if (c === 48 && codes[i + 1] === 5) { + bg = `var(--a${codes[i + 2]}, #888)` + i += 2 + } + } + + html += `${openSpan()}` + } + + return html + '' +} + +// ── Scenes ── + +interface Scene { + bg: string + fg: string + name: string + theme: Theme +} + +const setup = (bgHex: string) => { + process.env.HERMES_TUI_BACKGROUND = bgHex + resetOverlayState() + resetUiState() +} + +const scenes: Scene[] = [] + +const addScene = (name: string, bgHex: string, skin: Record) => { + setup(bgHex) + + const theme = fromSkin(skin, {}) + + scenes.push({ bg: bgHex, fg: theme.color.text, name, theme }) +} + +addScene('default · dark terminal', '#101014', {}) +addScene('default · light terminal (Cursor)', '#ffffff', {}) +addScene('slate · dark terminal', '#101014', SLATE) +addScene('slate · light terminal (raw palette + display shim)', '#ffffff', SLATE) + +let page = `
` + +for (const scene of scenes) { + setup(scene.bg) + patchUiState({ sid: 'd2a6ecf8', theme: scene.theme }) + + const intro = renderAnsi( + + + + , + 88 + ) + + patchOverlayState({}) + + const comps = renderAnsi( + + + + , + 88 + ) + + const statusLine = renderAnsi( + + + — ready + | opus 4.8 fast | 4s | voice off + + + {scene.theme.brand.prompt} + T + ry "fix the lint errors" + + , + 88 + ) + + page += `
` + page += `
${scene.name}
` + page += `
${ansiToHtml(intro, scene.fg, scene.bg)}
` + page += `
${ansiToHtml(comps, scene.fg, scene.bg)}
` + page += `
${ansiToHtml(statusLine, scene.fg, scene.bg)}
` + page += `
` +} + +page += '
' +writeFileSync('/tmp/tui-visual.html', page) +console.log('wrote /tmp/tui-visual.html') +process.exit(0) diff --git a/ui-tui/scripts/visual/shot.mjs b/ui-tui/scripts/visual/shot.mjs new file mode 100644 index 000000000000..1815ad484f27 --- /dev/null +++ b/ui-tui/scripts/visual/shot.mjs @@ -0,0 +1,23 @@ +// Screenshot /tmp/tui-visual.html with the repo's Electron (offscreen). +import { app, BrowserWindow } from 'electron' +import { writeFileSync } from 'fs' + +app.disableHardwareAcceleration() + +app.whenReady().then(async () => { + const win = new BrowserWindow({ + height: 2100, + show: false, + webPreferences: { offscreen: true }, + width: 1500 + }) + + await win.loadFile('/tmp/tui-visual.html') + await new Promise(r => setTimeout(r, 700)) + + const image = await win.webContents.capturePage() + + writeFileSync('/tmp/tui-visual.png', image.toPNG()) + console.log('wrote /tmp/tui-visual.png') + app.quit() +}) diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index 8e75a7861e2a..fe3ac5467795 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -208,18 +208,21 @@ describe('fromSkin', () => { const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {}) expect(theme.color.completionBg).toBe('#ffffff') - expect(theme.color.completionCurrentBg).toBe('#bfbfbf') + // Active row = authored surface mixed toward the accent (ladder knob). + expect(theme.color.completionCurrentBg).toBe('#c7c7c7') }) it('rejects wrong-polarity fills even when skin-authored (terminal owns the canvas)', async () => { // Dark terminal + white menu fill: unlike the desktop app, the TUI cannot - // paint its own canvas, so cross-polarity fills fall back to the base. - const { DARK_THEME, fromSkin } = await importThemeWithCleanEnv() + // paint its own canvas, so cross-polarity fills fall back to the derived + // ladder values, which are mixed from the real background and therefore + // polarity-correct by construction. + const { fromSkin } = await importThemeWithCleanEnv() const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {}) - expect(theme.color.completionBg).toBe(DARK_THEME.color.completionBg) - expect(theme.color.completionCurrentBg).toBe(DARK_THEME.color.completionCurrentBg) + expect(luminance(theme.color.completionBg)).toBeLessThanOrEqual(0.35) + expect(luminance(theme.color.completionCurrentBg)).toBeLessThanOrEqual(0.35) }) it('uses active completion color as the selection highlight fallback', async () => { @@ -300,14 +303,13 @@ describe('fromSkin', () => { }) it('keeps truecolor light Apple Terminal in truecolor (adapting, not ansi256-bucketing)', async () => { - const { fromSkin } = await importThemeWithEnv({ COLORTERM: 'truecolor', TERM_PROGRAM: 'Apple_Terminal' }) + const { contrastRatio, fromSkin } = await importThemeWithEnv({ COLORTERM: 'truecolor', TERM_PROGRAM: 'Apple_Terminal' }) const theme = fromSkin({ banner_text: '#FFF8DC' }, {}) - // No ansi256 bucketing on truecolor terminals — but a cream foreground on - // a light background is exactly the washout the adaptation exists to fix, - // so the value is clamped to a readable truecolor hex rather than kept. + // No ansi256 bucketing on truecolor terminals — a truly invisible cream + // (1.08:1 on white) still gets the display shim's gentle rescue. expect(theme.color.text).toMatch(/^#[0-9a-f]{6}$/i) - expect(luminance(theme.color.text)).toBeLessThanOrEqual(0.45) + expect(contrastRatio(theme.color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.45) }) it('normalizes Apple Terminal names before matching', async () => { @@ -368,20 +370,101 @@ const SLATE_COLORS = { ui_warn: '#e6a855' } +// Max per-channel deviation between two hexes. +const channelDelta = (a: string, b: string) => { + const pa = parseInt(a.replace('#', ''), 16) + const pb = parseInt(b.replace('#', ''), 16) + + return Math.max( + Math.abs(((pa >> 16) & 0xff) - ((pb >> 16) & 0xff)), + Math.abs(((pa >> 8) & 0xff) - ((pb >> 8) & 0xff)), + Math.abs((pa & 0xff) - (pb & 0xff)) + ) +} + +describe('derived tone ladder', () => { + it('reproduces the original hand-tuned tones from seeds (reverse-engineered knobs)', async () => { + // The ladder's knobs were grid-search fitted so the MATH lands on the + // pre-refactor hand-tuned literals. Contract: every derived tone stays + // within a-few-RGB-units of the original (imperceptible), so knob edits + // that drift the classic look fail here instead of shipping as vibes. + const dark = await importThemeWithCleanEnv() + const light = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' }) + + const cases: Array<[string, string, string]> = [ + [dark.DARK_THEME.color.muted, '#CC9B1F', 'dark muted'], + [dark.DARK_THEME.color.label, '#DAA520', 'dark label'], + [dark.DARK_THEME.color.statusFg, '#C0C0C0', 'dark statusFg'], + [dark.DARK_THEME.color.completionBg, '#1a1a2e', 'dark surface'], + [dark.DARK_THEME.color.completionCurrentBg, '#333355', 'dark chip'], + [dark.DARK_THEME.color.selectionBg, '#3a3a55', 'dark selection'], + [light.LIGHT_THEME.color.muted, '#7A5A0F', 'light muted'], + [light.LIGHT_THEME.color.statusFg, '#333333', 'light statusFg'], + [light.LIGHT_THEME.color.completionBg, '#F5F5F5', 'light surface'], + [light.LIGHT_THEME.color.completionCurrentBg, '#e0d1bf', 'light chip'], + [light.LIGHT_THEME.color.selectionBg, '#D4E4F7', 'light selection'] + ] + + for (const [got, original, label] of cases) { + expect(channelDelta(got, original), `${label}: ${got} vs original ${original}`).toBeLessThanOrEqual(8) + } + }) + + it('derives dim/secondary tones from the skin identity, not another palette', async () => { + const { fromSkin } = await importThemeWithCleanEnv() + + // A seeds-only skin (no dim/label/menu keys authored at all). + const { color } = fromSkin( + { banner_accent: '#DD4A3A', banner_text: '#F1E6CF', banner_title: '#C7A96B' }, + {} + ) + + // Muted recedes from THIS skin's text toward the background with an + // accent tint — a red-family derivative, never another skin's gold. + expect(color.muted).not.toBe(color.text) + expect(luminance(color.muted)).toBeLessThan(luminance(color.text)) + + const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16)) + const [mr, , mb] = rgb(color.muted) + + expect(mr).toBeGreaterThan(mb!) + + // The active-row chip is the surface tinted with the skin accent — + // redder than the plain surface. + const [sr] = rgb(color.completionBg) + + expect(rgb(color.completionCurrentBg)[0]).toBeGreaterThan(sr!) + }) + + it('authored tones still override the ladder', async () => { + const { fromSkin } = await importThemeWithCleanEnv() + const { color } = fromSkin({ banner_dim: '#AA8844', banner_text: '#F1E6CF' }, {}) + + expect(color.muted).toBe('#AA8844') + expect(color.sessionLabel).toBe('#AA8844') + }) +}) + describe('background-aware adaptation (OSC-11 light terminals)', () => { - it('keeps a dark-authored skin readable on a light background', async () => { + it('renders a dark-authored skin on light like minimumContrastRatio hosts do (the standardized look)', async () => { const { contrastRatio, fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' }) const { color } = fromSkin(SLATE_COLORS, {}) - // Foreground roles must clear WCAG contrast against the actual white - // background — hue survives, washout doesn't. - for (const key of ['text', 'prompt', 'accent', 'label', 'ok', 'error', 'primary'] as const) { - expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(3.8) + // The authored palette IS the design: slate's airy pastels (~1.5:1) pass + // through BYTE-IDENTICAL — that receded look is the standardized + // rendering, not a washout to fix. + expect(color.text.toLowerCase()).toBe('#c9d1d9') + expect(color.accent.toLowerCase()).toBe('#7eb8f6') + expect(color.muted.toLowerCase()).toBe('#4b5563') + + // Colors below the display floor get xterm's hue-preserving rescue. + for (const key of ['text', 'prompt', 'accent', 'label', 'primary', 'muted', 'border'] as const) { + expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.45) } - // Softer roles (muted/warn/border) get a looser but real floor. - for (const key of ['muted', 'warn', 'border'] as const) { - expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(2.7) + // Semantic alert colors carry meaning — firmer floor. + for (const key of ['ok', 'error', 'warn', 'statusGood', 'statusCritical'] as const) { + expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(2.15) } // Background roles the skin never defined must be light-polarity fills, @@ -391,6 +474,21 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => { } }) + it('rescues near-invisible colors with a hue-preserving multiplicative lift', async () => { + const { contrastRatio, fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' }) + // The default dark cream (#FFF8DC, 1.08:1 on white) is genuinely invisible. + const { color } = fromSkin({ banner_text: '#FFF8DC' }, {}) + + expect(color.text.toLowerCase()).not.toBe('#fff8dc') + expect(contrastRatio(color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.45) + + // Multiplicative lift preserves channel ordering (warm stays warm). + const [r, g, b] = [1, 3, 5].map(i => parseInt(color.text.slice(i, i + 2), 16)) + + expect(r).toBeGreaterThanOrEqual(g!) + expect(g).toBeGreaterThanOrEqual(b!) + }) + it('leaves the same skin untouched on a dark background', async () => { const { fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#1e1e2e' }) const { color } = fromSkin(SLATE_COLORS, {}) @@ -411,16 +509,17 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => { expect(dark.fromSkin({}, {}).color).toEqual(dark.DARK_THEME.color) - const light = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#fafafa' }) + const light = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' }) expect(light.fromSkin({}, {}).color).toEqual(light.LIGHT_THEME.color) }) it('defaultThemeForCurrentBackground follows a late HERMES_TUI_BACKGROUND write', async () => { - const { DEFAULT_THEME, defaultThemeForCurrentBackground, LIGHT_THEME } = await importThemeWithCleanEnv() + const { DARK_THEME, DEFAULT_THEME, defaultThemeForCurrentBackground, LIGHT_THEME } = await importThemeWithCleanEnv() // Module loaded dark (clean env)… - expect(DEFAULT_THEME.color.completionBg).toBe('#1a1a2e') + expect(DEFAULT_THEME.color.completionBg).toBe(DARK_THEME.color.completionBg) + expect(luminance(DEFAULT_THEME.color.completionBg)).toBeLessThanOrEqual(0.35) // …then the OSC-11 answer lands and is cached into the env slot. expect(defaultThemeForCurrentBackground({ HERMES_TUI_BACKGROUND: '#ffffff' }).color).toEqual(LIGHT_THEME.color) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index bcf70f0f577f..f8746fadb9f7 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,3 +1,5 @@ +import { execFile } from 'child_process' + import { onTerminalBackground } from '@hermes/ink' import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js' @@ -16,7 +18,8 @@ import { openExternalUrl } from '../lib/openExternalUrl.js' import { rpcErrorMessage } from '../lib/rpc.js' import { topLevelSubagents } from '../lib/subagentTree.js' import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js' -import { defaultThemeForCurrentBackground, detectLightMode, fromSkin } from '../theme.js' +import { writeBootTheme } from '../lib/themeBoot.js' +import { defaultThemeForCurrentBackground, detectLightMode, fromSkin, type Theme } from '../theme.js' import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' @@ -45,15 +48,22 @@ const themeForSkin = (s: GatewaySkin) => { return fromSkin(colors, s.branding ?? {}, s.banner_logo ?? '', s.banner_hero ?? '', s.tool_prefix ?? '', s.help_header ?? '') } +// Patch the live theme AND persist it for the next launch's first frame +// (flash-free boot — see lib/themeBoot.ts). +const commitTheme = (theme: Theme) => { + patchUiState({ theme }) + writeBootTheme(theme, process.env.HERMES_TUI_BACKGROUND) +} + const applySkin = (s: GatewaySkin) => { lastSkin = s - patchUiState({ theme: themeForSkin(s) }) + commitTheme(themeForSkin(s)) } /** Re-derive the theme from current detection signals (env overrides, cached * OSC-11 answer) — used by /theme, config sync, and the OSC listener. */ export function reapplyTheme(): void { - patchUiState({ theme: lastSkin ? themeForSkin(lastSkin) : defaultThemeForCurrentBackground() }) + commitTheme(lastSkin ? themeForSkin(lastSkin) : defaultThemeForCurrentBackground()) } /** @@ -109,20 +119,55 @@ export function syncThemeToTerminalBackground(): void { themeBackgroundSyncStarted = true + let resolved = false + onTerminalBackground(hex => { // xterm.js hosts (VS Code / Cursor) answer OSC 11 with #000000 when the // editor theme sets no explicit terminal background — the renderer's // unset DEFAULT, not the painted color (observed: pure black reported on // a white terminal). A real vscode dark theme reports its actual surface // (#1e1e1e etc.), so exactly-#000000 from a vscode host carries no - // signal: skip the cache and leave detection to env/config overrides. + // signal: skip the cache and fall through to the OS-appearance inference. if (hex === '#000000' && (process.env.TERM_PROGRAM ?? '') === 'vscode') { return } + resolved = true process.env.HERMES_TUI_BACKGROUND = hex reapplyTheme() }) + + // Last-resort inference when the probe never answers (or answered with the + // untrusted default): on macOS, editor themes overwhelmingly track the + // system appearance, so `AppleInterfaceStyle` is a strong prior. Runs only + // when no explicit signal exists (env pins/COLORFGBG all beat the cache + // slot this writes), after giving the probe a beat to answer. + setTimeout(() => { + if ( + resolved || + process.platform !== 'darwin' || + process.env.HERMES_TUI_BACKGROUND || + process.env.HERMES_TUI_THEME || + process.env.HERMES_TUI_LIGHT || + process.env.COLORFGBG + ) { + return + } + + execFile('defaults', ['read', '-g', 'AppleInterfaceStyle'], (error, stdout) => { + if (resolved || process.env.HERMES_TUI_BACKGROUND || process.env.HERMES_TUI_THEME) { + return + } + + // `defaults read` exits non-zero when the key is absent — which MEANS + // light mode; "Dark" means dark. Cache as an inferred background so + // every later signal (config pin, real OSC answer) still outranks it. + const dark = !error && stdout.trim() === 'Dark' + + process.env.HERMES_TUI_BACKGROUND = dark ? '#1e1e1e' : '#ffffff' + reapplyTheme() + }) + }, 1500).unref?.() } const dropBgTask = (taskId: string) => diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 00102f577213..f311e8f2ce56 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -2,6 +2,7 @@ import { atom, computed } from 'nanostores' import { MOUSE_TRACKING } from '../config/env.js' import { ZERO } from '../domain/usage.js' +import { bootTheme } from '../lib/themeBoot.js' import { DEFAULT_THEME } from '../theme.js' import { DEFAULT_INDICATOR_STYLE, type UiState } from './interfaces.js' @@ -30,7 +31,9 @@ const buildUiState = (): UiState => ({ status: 'summoning hermes…', statusBar: 'top', streaming: true, - theme: DEFAULT_THEME, + // Last session's resolved theme paints frame one (flash-free boot, like + // the desktop's hermes-boot-* keys); DEFAULT_THEME only on first launch. + theme: bootTheme ?? DEFAULT_THEME, usage: ZERO }) diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 60e4bf248584..be22e2bf83a5 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -375,7 +375,9 @@ export function FloatingOverlays({
{item.meta ? ( - + // Active row: meta rides the chip, so it uses the row ink — + // muted-on-chip can drop under 1.5:1 on dark accent chips. + {' '} {item.meta} diff --git a/ui-tui/src/components/overlayPrimitives.tsx b/ui-tui/src/components/overlayPrimitives.tsx index d24fa9fc0e74..a001011e5f78 100644 --- a/ui-tui/src/components/overlayPrimitives.tsx +++ b/ui-tui/src/components/overlayPrimitives.tsx @@ -3,6 +3,7 @@ import { Text, useInput } from '@hermes/ink' import { type ReactNode, useState } from 'react' import type { UsageModelData } from '../gatewayTypes.js' +import { liftForContrast } from '../lib/color.js' import type { Theme } from '../theme.js' export interface MenuRowSpec { @@ -60,7 +61,16 @@ export function useMenu(rows: MenuRowSpec[], onEscape: () => void, onKey?: (ch: * correct on any terminal background. Callers own layout; this owns color. */ export function listRowStyle(t: Theme, active: boolean): { backgroundColor?: string; color?: string } { - return active ? { backgroundColor: t.color.completionCurrentBg, color: t.color.text } : {} + if (!active) { + return {} + } + + const backgroundColor = t.color.completionCurrentBg + + // The chip guarantees its own ink: a cross-polarity theme (dark palette on + // a light terminal) pairs pale text with a light chip, so lift the ink + // against the ACTUAL chip fill with the xterm algorithm. + return { backgroundColor, color: liftForContrast(t.color.text, backgroundColor, 4.5) } } /** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). Active rows diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 3ac29d368edd..81f555db3033 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -613,14 +613,24 @@ export function TextInput({ const boxRef = useDeclaredCursor({ line: layout.line, column: layout.column, - active: focus && termFocus && !selected + // The placeholder state draws a synthetic cursor (see `rendered`), so the + // hardware cursor must not also be declared there — hosts paint it with + // their own cursor colors as a solid slab over the first glyph. + active: focus && termFocus && !selected && !(!display && !!placeholder) }) // Hide the hardware cursor while a selection is active (prevents // auto-wrap onto the next row when inverted text fills the column - // exactly) or when the terminal loses focus (suppresses the hollow-rect - // ghost most terminals draw at the parked position). - const hideHardwareCursor = focus && !!stdout?.isTTY && (!!selected || !termFocus) + // exactly), when the terminal loses focus (suppresses the hollow-rect + // ghost most terminals draw at the parked position), or while the + // placeholder is showing: hosts draw block cursors with their OWN + // cursor/cursorAccent colors, which can render as a solid slab that + // swallows the first placeholder glyph ("sk me anything…"). The + // placeholder state draws its own synthetic cursor instead (the + // bubbletea/bubbles textinput pattern: the cursor cell renders the first + // placeholder character, styled), so the hint is always fully legible. + const placeholderShowing = focus && !display && !!placeholder + const hideHardwareCursor = focus && !!stdout?.isTTY && (!!selected || !termFocus || placeholderShowing) useEffect(() => { if (!hideHardwareCursor || !stdout) { @@ -637,19 +647,21 @@ export function TextInput({ const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY // Placeholder text is just a hint, not a selection — render it in the - // theme's muted color (SGR dim as fallback) without inverse styling. In a - // TTY the hardware cursor parks at column 0 and visually marks the input - // start. Non-TTY surfaces still need the synthetic inverse first-char to - // draw a cursor at all. + // theme's muted color (SGR dim as fallback). The cursor over an empty + // input is SYNTHETIC (bubbles textinput pattern): the first placeholder + // character rendered inverse-muted, so the glyph stays legible under the + // "cursor" and the block never renders as a host-colored solid slab. The + // hardware cursor is hidden for this state (see hideHardwareCursor). const rendered = useMemo(() => { if (!focus) { return display || colorizeHint(placeholder, placeholderColor) } if (!display && placeholder) { - return nativeCursor - ? colorizeHint(placeholder, placeholderColor) - : invert(placeholder[0] ?? ' ') + colorizeHint(placeholder.slice(1), placeholderColor) + return ( + colorizeHint(invert(placeholder[0] ?? ' '), placeholderColor) + + colorizeHint(placeholder.slice(1), placeholderColor) + ) } if (selected) { diff --git a/ui-tui/src/lib/color.test.ts b/ui-tui/src/lib/color.test.ts new file mode 100644 index 000000000000..b19325ad0dfb --- /dev/null +++ b/ui-tui/src/lib/color.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' + +import { color, contrastRatio, ensureContrast, mix, parseColor, readableOn, relativeLuminance, toHex } from './color.js' + +describe('parseColor', () => { + it('parses hex6, hex3 and rgb() forms', () => { + expect(parseColor('#1a2b3c')).toEqual([0x1a, 0x2b, 0x3c]) + expect(parseColor('1a2b3c')).toEqual([0x1a, 0x2b, 0x3c]) + expect(parseColor('#abc')).toEqual([0xaa, 0xbb, 0xcc]) + expect(parseColor('rgb(220,255,220)')).toEqual([220, 255, 220]) + expect(parseColor('rgba(10, 20, 30, 0.5)')).toEqual([10, 20, 30]) + }) + + it('rejects garbage', () => { + expect(parseColor('')).toBeNull() + expect(parseColor('ansi256(245)')).toBeNull() + expect(parseColor('#12345')).toBeNull() + }) +}) + +describe('mix', () => { + it('lerps in sRGB and round-trips through toHex', () => { + expect(mix('#000000', '#ffffff', 0.5)).toBe('#808080') + expect(mix('#ff0000', '#0000ff', 0)).toBe('#ff0000') + expect(mix('#ff0000', '#0000ff', 1)).toBe('#0000ff') + expect(toHex(parseColor(mix('#123456', '#654321', 0.3))!)).toBe(mix('#123456', '#654321', 0.3)) + }) + + it('passes unparseable inputs through unchanged', () => { + expect(mix('ansi256(245)', '#ffffff', 0.5)).toBe('ansi256(245)') + expect(mix('#ff0000', 'nope', 0.5)).toBe('#ff0000') + }) +}) + +describe('contrast', () => { + it('measures WCAG ratios at the anchors', () => { + expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 0) + expect(contrastRatio('#ffffff', '#ffffff')).toBeCloseTo(1, 5) + expect(relativeLuminance('#ffffff')).toBeCloseTo(1, 5) + expect(relativeLuminance('#000000')).toBeCloseTo(0, 5) + }) + + it('readableOn picks the ink pole', () => { + expect(readableOn('#ffffff')).toBe('#000000') + expect(readableOn('#101014')).toBe('#ffffff') + }) + + it('ensureContrast lifts failing colors monotonically and leaves passing ones alone', () => { + const pale = '#FFF8DC' + const fixed = ensureContrast(pale, '#ffffff', 3.9) + + expect(contrastRatio(fixed, '#ffffff')!).toBeGreaterThanOrEqual(3.9) + expect(ensureContrast('#3D2F13', '#ffffff', 3.9)).toBe('#3D2F13') + expect(ensureContrast('ansi256(245)', '#ffffff', 3.9)).toBe('ansi256(245)') + }) +}) + +describe('color() chain', () => { + it('composes the ladder operations', () => { + const out = color('#F1E6CF').mix('#101014', 0.35).mix('#DD4A3A', 0.18).ensureContrast('#101014', 2.8).hex() + + expect(out).toMatch(/^#[0-9a-f]{6}$/) + expect(contrastRatio(out, '#101014')!).toBeGreaterThanOrEqual(2.8) + expect(color('#808080').luminance()).toBeCloseTo(relativeLuminance('#808080')!, 10) + }) +}) diff --git a/ui-tui/src/lib/color.ts b/ui-tui/src/lib/color.ts new file mode 100644 index 000000000000..3c95076298c7 --- /dev/null +++ b/ui-tui/src/lib/color.ts @@ -0,0 +1,307 @@ +/** + * THE color primitive — every color computation in the TUI goes through this + * module (the twin of the desktop app's `src/themes/color.ts`). No component + * or theme code does its own hex parsing or channel math: parse, mix, measure + * and fix colors here, so tone ladders, contrast floors and one-off UI needs + * all share one set of semantics. + * + * Mixing is an sRGB lerp — deliberately, for byte parity with the desktop's + * `color-mix(in srgb, ...)` ladder in styles.css. + */ + +export type Rgb = readonly [number, number, number] + +const HEX6_RE = /^#?([0-9a-f]{6})$/i +const HEX3_RE = /^#?([0-9a-f]{3})$/i +const RGB_FN_RE = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/i + +const clampChannel = (v: number) => Math.max(0, Math.min(255, Math.round(v))) + +/** Parse `#rgb`, `#rrggbb` or `rgb(r,g,b)` → channels. Null for anything else. */ +export function parseColor(input: string): Rgb | null { + const value = input.trim() + + let m = HEX6_RE.exec(value) + + if (m) { + const n = parseInt(m[1]!, 16) + + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff] + } + + m = HEX3_RE.exec(value) + + if (m) { + const [r, g, b] = m[1]! + + return [parseInt(r! + r!, 16), parseInt(g! + g!, 16), parseInt(b! + b!, 16)] + } + + m = RGB_FN_RE.exec(value) + + if (m) { + return [clampChannel(Number(m[1])), clampChannel(Number(m[2])), clampChannel(Number(m[3]))] + } + + return null +} + +export const toHex = (rgb: Rgb): string => + '#' + rgb.map(c => clampChannel(c).toString(16).padStart(2, '0')).join('') + +/** sRGB lerp `a → b` by `t` in [0,1]. Unparseable inputs return `a` unchanged. */ +export function mix(a: string, b: string, t: number): string { + const pa = parseColor(a) + const pb = parseColor(b) + + if (!pa || !pb) { + return a + } + + return toHex([pa[0] + (pb[0] - pa[0]) * t, pa[1] + (pb[1] - pa[1]) * t, pa[2] + (pb[2] - pa[2]) * t]) +} + +function channelLuminance(value: number): number { + const normalized = value / 255 + + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 +} + +/** WCAG relative luminance in [0,1]. Null when unparseable. */ +export function relativeLuminance(color: string): null | number { + const rgb = parseColor(color) + + return rgb ? 0.2126 * channelLuminance(rgb[0]) + 0.7152 * channelLuminance(rgb[1]) + 0.0722 * channelLuminance(rgb[2]) : null +} + +/** WCAG contrast ratio between two colors (1–21). Null when unparseable. */ +export function contrastRatio(a: string, b: string): null | number { + const la = relativeLuminance(a) + const lb = relativeLuminance(b) + + if (la === null || lb === null) { + return null + } + + const [hi, lo] = la >= lb ? [la, lb] : [lb, la] + + return (hi + 0.05) / (lo + 0.05) +} + +/** The readable ink pole for a given background (desktop `readableOn`). */ +export function readableOn(bg: string): '#000000' | '#ffffff' { + return (relativeLuminance(bg) ?? 0) > 0.5 ? '#000000' : '#ffffff' +} + +/** + * Step-mix `color` toward the readable pole of `bg` until the contrast ratio + * clears `min` (desktop `ensureContrast`). Each step re-mixes from the + * ORIGINAL color so hue decays linearly, not exponentially. Returns the + * original when it already passes or isn't parseable. + */ +export function ensureContrast(color: string, bg: string, min: number): string { + if (relativeLuminance(bg) === null || parseColor(color) === null) { + return color + } + + const pole = readableOn(bg) + let current = color + + for (let step = 0; step <= 20; step++) { + const ratio = contrastRatio(current, bg) + + if (ratio === null || ratio >= min) { + return current + } + + current = mix(color, pole, Math.min(1, (step + 1) * 0.05)) + } + + return current +} + +/** + * xterm.js's minimum-contrast algorithm (Color.ts reduce/increaseLuminance), + * ported faithfully: multiplicative 10% channel steps toward the readable + * pole, which preserves channel RATIOS (hue and perceived chroma) far better + * than mixing toward black/white. This is the display shim's lift — the same + * math terminals themselves use, so a palette lifted here matches what hosts + * like VS Code/Cursor produce when their own minimumContrastRatio kicks in. + * Colors already at or above `ratio` pass through byte-identical. + */ +export function liftForContrast(color: string, bg: string, ratio: number): string { + const fg = parseColor(color) + const bgLum = relativeLuminance(bg) + + if (!fg || bgLum === null) { + return color + } + + // Byte-identical passthrough when the color already clears the ratio — + // the authored value is the design; only failing colors get touched. + if ((contrastRatio(color, bg) ?? 21) >= ratio) { + return color + } + + let [r, g, b] = fg + + if (bgLum > 0.5) { + // reduceLuminance: darken multiplicatively. + let cr = contrastRatio(toHex([r, g, b]), bg) ?? 21 + + while (cr < ratio && (r > 0 || g > 0 || b > 0)) { + r -= Math.ceil(r * 0.1) + g -= Math.ceil(g * 0.1) + b -= Math.ceil(b * 0.1) + cr = contrastRatio(toHex([r, g, b]), bg) ?? 21 + } + } else { + // increaseLuminance: brighten toward white in 10% remaining-headroom steps. + let cr = contrastRatio(toHex([r, g, b]), bg) ?? 21 + + while (cr < ratio && (r < 255 || g < 255 || b < 255)) { + r = Math.min(255, r + Math.ceil((255 - r) * 0.1)) + g = Math.min(255, g + Math.ceil((255 - g) * 0.1)) + b = Math.min(255, b + Math.ceil((255 - b) * 0.1)) + cr = contrastRatio(toHex([r, g, b]), bg) ?? 21 + } + } + + return toHex([r, g, b]) +} + +/** Recede toward the background pole (opposite of `readableOn`). */ +export const lighten = (color: string, t: number) => mix(color, '#ffffff', t) +export const darken = (color: string, t: number) => mix(color, '#000000', t) + +/** The luminance-weighted gray of a color (its perceptual brightness). */ +export function grayOf(color: string): string { + const rgb = parseColor(color) + + if (!rgb) { + return color + } + + const gray = clampChannel(0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) + + return toHex([gray, gray, gray]) +} + +/** Pull a color toward its own gray by `s` in [0,1] (1 = fully gray). */ +export const desaturate = (color: string, s: number) => mix(color, grayOf(color), s) + +/** RGB → HSL, channels in [0,1]. */ +export function toHsl(rgb: Rgb): [number, number, number] { + const r = rgb[0] / 255 + const g = rgb[1] / 255 + const b = rgb[2] / 255 + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const l = (max + min) / 2 + + if (max === min) { + return [0, 0, l] + } + + const d = max - min + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + const h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4 + + return [h / 6, s, l] +} + +const hueChannel = (p: number, q: number, t: number): number => { + const tt = t < 0 ? t + 1 : t > 1 ? t - 1 : t + + if (tt < 1 / 6) { + return p + (q - p) * 6 * tt + } + + if (tt < 1 / 2) { + return q + } + + if (tt < 2 / 3) { + return p + (q - p) * (2 / 3 - tt) * 6 + } + + return p +} + +/** HSL → RGB, inputs in [0,1]. */ +export function fromHsl(h: number, s: number, l: number): Rgb { + if (s === 0) { + const v = Math.round(l * 255) + + return [v, v, v] + } + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s + const p = 2 * l - q + + return [ + Math.round(hueChannel(p, q, h + 1 / 3) * 255), + Math.round(hueChannel(p, q, h) * 255), + Math.round(hueChannel(p, q, h - 1 / 3) * 255) + ] +} + +/** + * Re-tone a color while PRESERVING its hue: clamp saturation into + * [minSaturation, 1] and pin lightness. This is how a light-terminal variant + * of a pastel dark-terminal accent stays vivid — darkening by mixing toward + * black muddies pastels (kills saturation with lightness); re-toning keeps + * the color identity and moves only where it sits. + */ +export function retone(color: string, lightness: number, minSaturation = 0): string { + const rgb = parseColor(color) + + if (!rgb) { + return color + } + + const [h, s] = toHsl(rgb) + + return toHex(fromHsl(h, Math.max(s, minSaturation), lightness)) +} + +/** + * Chainable form for multi-step derivations, e.g. + * `color(text).mix(bg, 0.35).mix(accent, 0.18).ensureContrast(bg, 2.8).hex()`. + * Unparseable inputs pass through every operation unchanged. + */ +export function color(value: string): ColorChain { + return new ColorChain(value) +} + +class ColorChain { + constructor(private readonly value: string) {} + + mix(other: string, t: number): ColorChain { + return new ColorChain(mix(this.value, other, t)) + } + + lighten(t: number): ColorChain { + return new ColorChain(lighten(this.value, t)) + } + + darken(t: number): ColorChain { + return new ColorChain(darken(this.value, t)) + } + + ensureContrast(bg: string, min: number): ColorChain { + return new ColorChain(ensureContrast(this.value, bg, min)) + } + + luminance(): null | number { + return relativeLuminance(this.value) + } + + contrastOn(bg: string): null | number { + return contrastRatio(this.value, bg) + } + + hex(): string { + return this.value + } +} diff --git a/ui-tui/src/lib/themeBoot.ts b/ui-tui/src/lib/themeBoot.ts new file mode 100644 index 000000000000..0471eaa2008e --- /dev/null +++ b/ui-tui/src/lib/themeBoot.ts @@ -0,0 +1,119 @@ +/** + * Flash-free theme boot — the TUI port of the desktop app's + * `hermes-boot-background` / `hermes-boot-color-scheme` localStorage keys. + * + * Theme resolution is asynchronous by nature (gateway skin arrives after + * connect; the OSC-11 background probe answers after the first frame; the + * config mode pin arrives with config sync), so without a cache every launch + * repaints through default-dark → skin → detected-mode. This module persists + * the LAST RESOLVED theme + background to disk and replays them as the very + * first frame, so a stable setup renders correctly from paint one and the + * async signals merely confirm it. + * + * The cache is a hint, never an authority: explicit env pins beat it, and + * every later signal overwrites it (then persists the new answer). + */ + +import { readFileSync, renameSync, writeFileSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' + +import type { Theme } from '../theme.js' + +interface BootThemeFile { + /** The resolved background hex that detection settled on, if any. */ + background?: string + /** The fully-resolved Theme (palette + brand) from the last session. */ + theme?: Theme + version: 1 +} + +// Profile-aware: the Python launcher exports HERMES_HOME (set by +// _apply_profile_override) before spawning the TUI. Falling back to +// ~/.hermes matches get_hermes_home()'s default. +const bootFilePath = () => join(process.env.HERMES_HOME ?? join(homedir(), '.hermes'), 'tui-theme-boot.json') + +// Never touch the user's real ~/.hermes from test runs (the TS suite has no +// HERMES_HOME isolation fixture). +const isTestRun = () => !!process.env.VITEST || process.env.NODE_ENV === 'test' + +const looksLikeTheme = (value: unknown): value is Theme => { + if (typeof value !== 'object' || value === null) { + return false + } + + const theme = value as Partial + + return ( + typeof theme.color === 'object' && + theme.color !== null && + typeof theme.color.text === 'string' && + typeof theme.color.primary === 'string' && + typeof theme.brand === 'object' && + theme.brand !== null && + typeof theme.brand.name === 'string' + ) +} + +/** Read the cached boot theme. Null on first launch / damage / test runs. */ +export function readBootTheme(): { background?: string; theme: Theme } | null { + if (isTestRun()) { + return null + } + + try { + const raw = JSON.parse(readFileSync(bootFilePath(), 'utf8')) as BootThemeFile + + if (raw.version !== 1 || !looksLikeTheme(raw.theme)) { + return null + } + + return { background: typeof raw.background === 'string' ? raw.background : undefined, theme: raw.theme } + } catch { + return null + } +} + +let writeTimer: NodeJS.Timeout | null = null + +/** Persist the resolved theme (debounced, atomic, fire-and-forget). */ +export function writeBootTheme(theme: Theme, background?: string): void { + if (isTestRun()) { + return + } + + if (writeTimer) { + clearTimeout(writeTimer) + } + + writeTimer = setTimeout(() => { + writeTimer = null + + try { + const payload: BootThemeFile = { background, theme, version: 1 } + const path = bootFilePath() + const tmp = `${path}.tmp` + + writeFileSync(tmp, JSON.stringify(payload)) + renameSync(tmp, path) + } catch { + // Cache write failures are cosmetic — next launch just flashes once. + } + }, 400) + + writeTimer.unref?.() +} + +/** + * Boot-time seeding, run once at module load (imported by uiStore before the + * first render): make the cached background visible to `detectLightMode` + * unless an explicit signal already outranks it. + */ +const boot = readBootTheme() + +if (boot?.background && !process.env.HERMES_TUI_BACKGROUND && !process.env.HERMES_TUI_THEME && !process.env.HERMES_TUI_LIGHT) { + process.env.HERMES_TUI_BACKGROUND = boot.background +} + +/** The cached theme for the first frame, or null on first launch. */ +export const bootTheme: Theme | null = boot?.theme ?? null diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 850fcca4c7ca..690b4aa84263 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -1,3 +1,5 @@ +import { desaturate, grayOf, liftForContrast, mix, parseColor, relativeLuminance, toHex } from './lib/color.js' + export interface ThemeColors { primary: string accent: string @@ -52,31 +54,13 @@ export interface Theme { } // ── Color math ─────────────────────────────────────────────────────── +// +// All generic color computation lives in lib/color.ts (the color primitive); +// this file keeps only the ANSI-256 remapping that is specific to the +// limited-palette Apple Terminal path. contrastRatio/ensureContrast are +// re-exported for existing consumers (tests, /theme-info). -function parseHex(h: string): [number, number, number] | null { - const m = /^#?([0-9a-f]{6})$/i.exec(h) - - if (!m) { - return null - } - - const n = parseInt(m[1]!, 16) - - return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff] -} - -function mix(a: string, b: string, t: number) { - const pa = parseHex(a) - const pb = parseHex(b) - - if (!pa || !pb) { - return a - } - - const lerp = (i: 0 | 1 | 2) => Math.round(pa[i] + (pb[i] - pa[i]) * t) - - return '#' + ((1 << 24) | (lerp(0) << 16) | (lerp(1) << 8) | lerp(2)).toString(16).slice(1) -} +export { contrastRatio, ensureContrast } from './lib/color.js' const XTERM_6_LEVELS = [0, 95, 135, 175, 215, 255] as const const ANSI_LIGHT_MAX_LUMINANCE = 0.72 @@ -121,15 +105,8 @@ function xtermEightBitRgb(colorNumber: number): [number, number, number] { return [0, 0, 0] } -function channelLuminance(value: number): number { - const normalized = value / 255 - - return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 -} - -function relativeLuminance(red: number, green: number, blue: number): number { - return 0.2126 * channelLuminance(red) + 0.7152 * channelLuminance(green) + 0.0722 * channelLuminance(blue) -} +const rgbLuminance = (red: number, green: number, blue: number): number => + relativeLuminance(toHex([red, green, blue])) ?? 0 function rgbToHsl(red: number, green: number, blue: number): [number, number, number] { const rn = red / 255 @@ -158,12 +135,6 @@ function circularDistance(a: number, b: number): number { return Math.min(distance, 1 - distance) } -function hexLuminance(color: string): null | number { - const rgb = parseHex(color) - - return rgb ? relativeLuminance(rgb[0], rgb[1], rgb[2]) : null -} - // Mirrors @hermes/ink's colorize.ts. Keep local: app code compiles from // ui-tui/src, while @hermes/ink is bundled separately from packages/. function richEightBitColorNumber(red: number, green: number, blue: number): number { @@ -189,7 +160,7 @@ function bestReadableAnsiColor(red: number, green: number, blue: number): number for (let colorNumber = 16; colorNumber <= 255; colorNumber += 1) { const [candidateRed, candidateGreen, candidateBlue] = xtermEightBitRgb(colorNumber) - const candidateLuminance = relativeLuminance(candidateRed, candidateGreen, candidateBlue) + const candidateLuminance = rgbLuminance(candidateRed, candidateGreen, candidateBlue) if (candidateLuminance > ANSI_LIGHT_MAX_LUMINANCE) { continue @@ -220,7 +191,7 @@ function bestReadableAnsiColor(red: number, green: number, blue: number): number } function normalizeAnsiForeground(color: string): string { - const rgb = parseHex(color) + const rgb = parseColor(color) if (!rgb) { return color @@ -230,7 +201,7 @@ function normalizeAnsiForeground(color: string): string { const richRgb = xtermEightBitRgb(richAnsi) const ansi = - relativeLuminance(richRgb[0], richRgb[1], richRgb[2]) > ANSI_LIGHT_MAX_LUMINANCE + rgbLuminance(richRgb[0], richRgb[1], richRgb[2]) > ANSI_LIGHT_MAX_LUMINANCE ? bestReadableAnsiColor(rgb[0], rgb[1], rgb[2]) : richAnsi @@ -257,97 +228,145 @@ const cleanPromptSymbol = (s: string | undefined, fallback: string) => { return cleaned || fallback } +// ── Seeds → palette ────────────────────────────────────────────────── +// +// A palette is BUILT, not enumerated: skins/base themes supply identity seeds +// (text, primary, accent, semantic hues) and every secondary tone is derived +// by the mix ladder against the background. This is the desktop token system +// (seeds → color-mix → tokens) in terminal form — "dim" is definitionally a +// derivative of the theme's own base colors and can never be incoherent. + +export interface ThemeSeeds { + accent: string + /** Identity fill override: active list-row chip (derived when omitted). */ + activeRow?: string + bg: string + border?: string + error: string + /** Identity tone override: muted/dim text (derived when omitted). */ + muted?: string + ok: string + primary: string + prompt?: string + /** Identity fill override: text-selection highlight (derived when omitted). */ + selection?: string + shellDollar: string + statusBad: string + statusCritical: string + statusGood: string + statusWarn: string + /** Identity fill override: panel/status surface (derived when omitted). */ + surface?: string + text: string + warn: string +} + +const DIFF_DARK = { + diffAdded: 'rgb(220,255,220)', + diffRemoved: 'rgb(255,220,220)', + diffAddedWord: 'rgb(36,138,61)', + diffRemovedWord: 'rgb(207,34,46)' +} + +const DIFF_LIGHT = { + diffAdded: 'rgb(200,240,200)', + diffRemoved: 'rgb(240,200,200)', + diffAddedWord: 'rgb(27,94,32)', + diffRemovedWord: 'rgb(183,28,28)' +} + +export function buildPalette(seeds: ThemeSeeds, isLight: boolean): ThemeColors { + const tones = deriveTones(seeds) + const surface = seeds.surface ?? tones.surface + const activeRow = seeds.activeRow ?? (seeds.surface ? mix(surface, seeds.accent, 0.22) : tones.activeRow) + const muted = seeds.muted ?? tones.muted + + return { + primary: seeds.primary, + accent: seeds.accent, + border: seeds.border ?? tones.border, + text: seeds.text, + muted, + completionBg: surface, + completionCurrentBg: activeRow, + completionMetaBg: surface, + completionMetaCurrentBg: activeRow, + + label: tones.label, + ok: seeds.ok, + error: seeds.error, + warn: seeds.warn, + + prompt: seeds.prompt ?? seeds.text, + // sessionLabel/sessionBorder track the muted tone — "same role, same + // colour" by design (#11300). + sessionLabel: muted, + sessionBorder: muted, + + statusBg: surface, + statusFg: tones.statusFg, + statusGood: seeds.statusGood, + statusWarn: seeds.statusWarn, + statusBad: seeds.statusBad, + statusCritical: seeds.statusCritical, + selectionBg: seeds.selection ?? tones.selection, + + ...(isLight ? DIFF_LIGHT : DIFF_DARK), + shellDollar: seeds.shellDollar + } +} + +export const DARK_SEEDS: ThemeSeeds = { + accent: '#FFBF00', + // The classic Hermes navy surfaces are IDENTITY, not derivation drift — + // keep them as explicit fill seeds (the ladder derives them for skins + // that don't care). + activeRow: '#333355', + bg: '#101014', + border: '#CD7F32', + error: '#ef5350', + ok: '#4caf50', + primary: '#FFD700', + prompt: '#FFF8DC', + selection: '#3a3a55', + shellDollar: '#4dabf7', + statusBad: '#FF8C00', + statusCritical: '#FF6B6B', + statusGood: '#8FBC8F', + statusWarn: '#FFD700', + surface: '#1a1a2e', + text: '#FFF8DC', + warn: '#ffa726' +} + +// Light-terminal seeds: darker golds/ambers that stay legible on white. +export const LIGHT_SEEDS: ThemeSeeds = { + accent: '#A0651C', + bg: '#ffffff', + border: '#7A4F1F', + error: '#C62828', + ok: '#2E7D32', + primary: '#8B6914', + prompt: '#2B2014', + shellDollar: '#1565C0', + statusBad: '#D84315', + statusCritical: '#B71C1C', + statusGood: '#2E7D32', + statusWarn: '#8B6914', + text: '#3D2F13', + warn: '#E65100' +} + export const DARK_THEME: Theme = { - color: { - primary: '#FFD700', - accent: '#FFBF00', - border: '#CD7F32', - text: '#FFF8DC', - muted: '#CC9B1F', - // Bumped from the old `#B8860B` darkgoldenrod (~53% luminance) which - // read as barely-visible on dark terminals for long body text. The - // new value sits ~60% luminance — readable without losing the "muted / - // secondary" semantic. Field labels still use `label` (65%) which - // stays brighter so hierarchy holds. - completionBg: '#1a1a2e', - completionCurrentBg: '#333355', - completionMetaBg: '#1a1a2e', - completionMetaCurrentBg: '#333355', - - label: '#DAA520', - ok: '#4caf50', - error: '#ef5350', - warn: '#ffa726', - - prompt: '#FFF8DC', - // sessionLabel/sessionBorder intentionally track the `dim` value — they - // are "same role, same colour" by design. fromSkin's banner_dim fallback - // relies on this pairing (#11300). - sessionLabel: '#CC9B1F', - sessionBorder: '#CC9B1F', - - statusBg: '#1a1a2e', - statusFg: '#C0C0C0', - statusGood: '#8FBC8F', - statusWarn: '#FFD700', - statusBad: '#FF8C00', - statusCritical: '#FF6B6B', - selectionBg: '#3a3a55', - - diffAdded: 'rgb(220,255,220)', - diffRemoved: 'rgb(255,220,220)', - diffAddedWord: 'rgb(36,138,61)', - diffRemovedWord: 'rgb(207,34,46)', - shellDollar: '#4dabf7' - }, - + color: buildPalette(DARK_SEEDS, false), brand: BRAND, - bannerLogo: '', bannerHero: '' } -// Light-terminal palette: darker golds/ambers that stay legible on white -// backgrounds. Same shape as DARK_THEME so `fromSkin` still layers on top -// cleanly (#11300). export const LIGHT_THEME: Theme = { - color: { - primary: '#8B6914', - accent: '#A0651C', - border: '#7A4F1F', - text: '#3D2F13', - muted: '#7A5A0F', - completionBg: '#F5F5F5', - completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25), - completionMetaBg: '#F5F5F5', - completionMetaCurrentBg: mix('#F5F5F5', '#A0651C', 0.25), - - label: '#7A5A0F', - ok: '#2E7D32', - error: '#C62828', - warn: '#E65100', - - prompt: '#2B2014', - sessionLabel: '#7A5A0F', - sessionBorder: '#7A5A0F', - - statusBg: '#F5F5F5', - statusFg: '#333333', - statusGood: '#2E7D32', - statusWarn: '#8B6914', - statusBad: '#D84315', - statusCritical: '#B71C1C', - selectionBg: '#D4E4F7', - - diffAdded: 'rgb(200,240,200)', - diffRemoved: 'rgb(240,200,200)', - diffAddedWord: 'rgb(27,94,32)', - diffRemovedWord: 'rgb(183,28,28)', - shellDollar: '#1565C0' - }, - + color: buildPalette(LIGHT_SEEDS, true), brand: BRAND, - bannerLogo: '', bannerHero: '' } @@ -369,75 +388,44 @@ export const LIGHT_THEME: Theme = { // background — so a wrong-polarity fill (navy menu on a white terminal) // falls back to the base palette even when the skin authored it. -/** WCAG contrast ratio between two hex colors (1–21). Null if unparseable. */ -export function contrastRatio(a: string, b: string): null | number { - const la = hexLuminance(a) - const lb = hexLuminance(b) +// Display shim — the "rendering gotcha" layer, calibrated against the look +// the maintainers standardized on (pixel-sampled from the reference +// screenshot): the beloved cross-polarity rendering is the AUTHORED palette +// displayed RAW — slate's ~1.5:1 pastels on white read as deliberate airy +// hierarchy, not a bug. So the floors are barely-visible rescues only: +// * DISPLAY 1.45 sits just above slate-pastel territory (#c9d1d9 = 1.54, +// passes raw, byte-identical) but just below true invisibility +// (default's cream #FFF8DC = 1.08, gets rescued). +// * SEMANTIC 2.2 for alert colors (ok/error/warn/status) — they carry +// meaning and must never vanish. +// The lift itself is xterm.js's own multiplicative algorithm +// (liftForContrast), so on hosts that run their own minimumContrastRatio the +// two adjustments agree instead of fighting. +const DISPLAY_MIN_CONTRAST = 1.45 +const SEMANTIC_MIN_CONTRAST = 2.2 - if (la === null || lb === null) { - return null - } - - const [hi, lo] = la >= lb ? [la, lb] : [lb, la] - - return (hi + 0.05) / (lo + 0.05) -} - -/** - * Step-mix `color` toward the readable pole of `bg` until the contrast - * ratio clears `min` (desktop `color.ts::ensureContrast`). Returns the - * original when it already passes or isn't hex. - */ -export function ensureContrast(color: string, bg: string, min: number): string { - const bgLuminance = hexLuminance(bg) - - if (bgLuminance === null || parseHex(color) === null) { - return color - } - - const pole = bgLuminance > 0.5 ? '#000000' : '#ffffff' - let current = color - - for (let step = 0; step <= 20; step++) { - const ratio = contrastRatio(current, bg) - - if (ratio === null || ratio >= min) { - return current - } - - current = mix(color, pole, Math.min(1, (step + 1) * 0.05)) - } - - return current -} - -// Contrast tiers, tuned so both base palettes are fixed points: primary -// text/labels/status readable at ≥ 3.9; muted/secondary/ambers at ≥ 2.8. -const STRONG_MIN_CONTRAST = 3.9 -const SOFT_MIN_CONTRAST = 2.8 - -const STRONG_FOREGROUNDS: readonly (keyof ThemeColors)[] = [ +const DISPLAY_FOREGROUNDS: readonly (keyof ThemeColors)[] = [ 'primary', 'accent', 'text', 'label', - 'ok', - 'error', 'prompt', 'statusFg', - 'statusGood', - 'statusBad', - 'statusCritical', + 'border', + 'muted', + 'sessionLabel', + 'sessionBorder', 'shellDollar' ] -const SOFT_FOREGROUNDS: readonly (keyof ThemeColors)[] = [ - 'border', - 'muted', +const SEMANTIC_FOREGROUNDS: readonly (keyof ThemeColors)[] = [ + 'ok', + 'error', 'warn', - 'sessionLabel', - 'sessionBorder', - 'statusWarn' + 'statusGood', + 'statusWarn', + 'statusBad', + 'statusCritical' ] const ADAPTIVE_BACKGROUNDS: readonly (keyof ThemeColors)[] = [ @@ -457,16 +445,16 @@ const DARK_BG_MAX_LUMINANCE = 0.35 function adaptColorsToBackground(colors: ThemeColors, isLight: boolean, base: ThemeColors, bg: string): ThemeColors { const out = { ...colors } - for (const key of STRONG_FOREGROUNDS) { - out[key] = ensureContrast(out[key], bg, STRONG_MIN_CONTRAST) + for (const key of DISPLAY_FOREGROUNDS) { + out[key] = liftForContrast(out[key], bg, DISPLAY_MIN_CONTRAST) } - for (const key of SOFT_FOREGROUNDS) { - out[key] = ensureContrast(out[key], bg, SOFT_MIN_CONTRAST) + for (const key of SEMANTIC_FOREGROUNDS) { + out[key] = liftForContrast(out[key], bg, SEMANTIC_MIN_CONTRAST) } for (const key of ADAPTIVE_BACKGROUNDS) { - const luminance = hexLuminance(out[key]) + const luminance = relativeLuminance(out[key]) if (luminance === null) { continue @@ -493,6 +481,80 @@ function referenceBackground(isLight: boolean, env: NodeJS.ProcessEnv = process. return isLight ? '#ffffff' : '#101014' } +// ── Derived tone ladder (the desktop color-mix system) ────────────── +// +// A theme is a handful of SEEDS (text, primary, accent, border, status hues); +// every secondary tone — muted text, labels, surfaces, selection chips — is a +// color-mix derivative of those seeds against the real terminal background, +// exactly like the desktop's `--theme-*` seeds → `--ui-*` color-mix ladder in +// apps/desktop/src/styles.css. Skins therefore cannot ship an incoherent +// "dim": if they don't author a tone, it is DERIVED from their own identity, +// never inherited from another skin's palette. +// +// Mix knobs are the single source of truth for tone hierarchy. (The classic +// prompt_toolkit CLI still reads the built-in skins' complete authored +// palettes; those were generated with the same math.) + +export interface ThemeTones { + /** Secondary/dim text: receded accent (dark) / primary-ink blend (light). */ + muted: string + /** Field labels: one step brighter than muted, same family. */ + label: string + /** Status-bar default text: the gray of slightly-receded text. */ + statusFg: string + /** Raised panel fill: background nudged toward the (softened) accent. */ + surface: string + /** Active list-row chip: surface tinted with accent. */ + activeRow: string + /** Text-selection highlight: bg tinted with the theme's blue (light) or accent (dark). */ + selection: string + /** Border fallback: accent receded toward the background. */ + border: string +} + +/** + * The fitted tone ladder. Knobs are REVERSE-ENGINEERED from the original + * hand-tuned palettes (grid-search over mix/desaturate formula families + * against the pre-refactor literals + every authored skin palette; see the + * "reproduces the original hand-tuned tones" test for the contract): + * + * dark muted #CC9B1F ≈ desaturate(mix(accent, bg, .19), .16) (err 3) + * dark label #DAA520 ≈ desaturate(mix(accent, bg, .13), .16) (err 3) + * dark status #C0C0C0 = grayOf(mix(text, bg, .24)) (err 0) + * light muted/label #7A5A0F ≈ mix(primary, text, .27) (err 5) + * light status #333333 = grayOf(mix(text, bg, .01)) (err 1) + * light surface #F5F5F5 ≈ bg + softened accent (err 5) + * light chip #E0D1BF = mix(surface, accent, .25) (err 3) + * light selection #D4E4F7 ≈ mix(bg, shellDollar, .17) (err 3) + * + * The classic dark navy fills (#1a1a2e/#333355/#3a3a55) are IRREDUCIBLE from + * gold seeds — the search bottoms out at gray, err 10–17 — so they remain + * explicit identity seeds on DARK_SEEDS rather than pretending to be math. + */ +export function deriveTones(seeds: { + accent: string + bg: string + primary: string + shellDollar?: string + text: string +}): ThemeTones { + const { accent, bg, primary, text } = seeds + const isLight = (relativeLuminance(bg) ?? 0) > 0.5 + const inkBlend = mix(primary, text, 0.27) + const surface = mix(bg, desaturate(accent, 0.35), isLight ? 0.045 : 0.09) + + return { + muted: isLight ? inkBlend : desaturate(mix(accent, bg, 0.19), 0.16), + label: isLight ? inkBlend : desaturate(mix(accent, bg, 0.13), 0.16), + statusFg: grayOf(mix(text, bg, isLight ? 0.01 : 0.24)), + surface, + activeRow: mix(surface, accent, 0.25), + selection: + isLight && seeds.shellDollar ? mix(bg, seeds.shellDollar, 0.17) : mix(surface, accent, 0.28), + border: mix(accent, bg, 0.25) + } +} + const TRUE_RE = /^(?:1|true|yes|on)$/ const FALSE_RE = /^(?:0|false|no|off)$/ @@ -680,70 +742,79 @@ export function fromSkin( ): Theme { // Live detection (not the module-load snapshot): by the time the gateway // skin arrives, the OSC-11 background probe has usually answered and cached - // itself into HERMES_TUI_BACKGROUND, so the fallback base — every color key - // the skin does NOT define — matches the actual terminal background instead - // of assuming dark. See #applySkin / syncThemeToTerminalBackground. + // itself into HERMES_TUI_BACKGROUND. See #applySkin / syncThemeToTerminalBackground. const isLight = detectLightMode() + const bg = referenceBackground(isLight) + const base = isLight ? LIGHT_SEEDS : DARK_SEEDS const d = isLight ? LIGHT_THEME : DARK_THEME const c = (k: string) => colors[k] + const hasSkinColors = Object.keys(colors).length > 0 - const accent = c('ui_accent') ?? c('banner_accent') ?? d.color.accent - const bannerAccent = c('banner_accent') ?? c('banner_title') ?? d.color.accent - const muted = c('banner_dim') ?? d.color.muted - const completionBg = c('completion_menu_bg') ?? d.color.completionBg + // 1. Seeds: the skin's identity. Anything it doesn't define comes from the + // base seeds for this polarity. The base's IDENTITY FILLS (Hermes navy + // surfaces, gold muted) only carry over for the skinless default — a + // skin with its own identity derives its fills from its own seeds. + const identityFills: Partial = hasSkinColors + ? {} + : { activeRow: base.activeRow, muted: base.muted, selection: base.selection, surface: base.surface } - const completionCurrentBg = - c('completion_menu_current_bg') ?? - (hasSkinColors ? mix(completionBg, bannerAccent, 0.25) : d.color.completionCurrentBg) - - const completionMetaBg = c('completion_menu_meta_bg') ?? completionBg - const completionMetaCurrentBg = c('completion_menu_meta_current_bg') ?? completionCurrentBg - - const assembled: ThemeColors = { - primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary, - accent, - border: c('ui_border') ?? c('banner_border') ?? d.color.border, - text: c('ui_text') ?? c('banner_text') ?? d.color.text, - muted, - completionBg, - completionCurrentBg, - completionMetaBg, - completionMetaCurrentBg, - - label: c('ui_label') ?? d.color.label, - ok: c('ui_ok') ?? d.color.ok, - error: c('ui_error') ?? d.color.error, - warn: c('ui_warn') ?? d.color.warn, - - prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt, - sessionLabel: c('session_label') ?? muted, - sessionBorder: c('session_border') ?? muted, - - statusBg: c('status_bar_bg') ?? d.color.statusBg, - statusFg: c('status_bar_text') ?? d.color.statusFg, - statusGood: c('status_bar_good') ?? c('ui_ok') ?? d.color.statusGood, - statusWarn: c('status_bar_warn') ?? c('ui_warn') ?? d.color.statusWarn, - statusBad: c('status_bar_bad') ?? d.color.statusBad, - statusCritical: c('status_bar_critical') ?? d.color.statusCritical, - selectionBg: - c('selection_bg') ?? - c('completion_menu_current_bg') ?? - (hasSkinColors ? completionCurrentBg : d.color.selectionBg), - - diffAdded: d.color.diffAdded, - diffRemoved: d.color.diffRemoved, - diffAddedWord: d.color.diffAddedWord, - diffRemovedWord: d.color.diffRemovedWord, - shellDollar: c('shell_dollar') ?? d.color.shellDollar + const seeds: ThemeSeeds = { + ...identityFills, + accent: c('ui_accent') ?? c('banner_accent') ?? base.accent, + bg, + border: c('ui_border') ?? c('banner_border') ?? base.border, + error: c('ui_error') ?? base.error, + ok: c('ui_ok') ?? base.ok, + primary: c('ui_primary') ?? c('banner_title') ?? base.primary, + prompt: c('prompt') ?? c('banner_text') ?? base.prompt, + shellDollar: c('shell_dollar') ?? base.shellDollar, + statusBad: c('status_bar_bad') ?? base.statusBad, + statusCritical: c('status_bar_critical') ?? c('ui_error') ?? base.statusCritical, + statusGood: c('status_bar_good') ?? c('ui_ok') ?? base.statusGood, + statusWarn: c('status_bar_warn') ?? c('ui_warn') ?? base.statusWarn, + text: c('ui_text') ?? c('banner_text') ?? base.text, + warn: c('ui_warn') ?? base.warn } - // Two exclusive readability strategies: ANSI-limited light Apple Terminal - // keeps its bespoke ansi256 normalization (below) byte-for-byte; every - // truecolor terminal gets contrast enforcement against the real background. + // 2. Derive: every secondary tone is a color-mix of the seeds against the + // REAL background — dim is a derivative of the skin's own identity. + const derived = buildPalette(seeds, isLight) + + // 3. Authored tone overrides: a skin may still hand-tune any tone; the + // derived ladder is the default, not a cage. Chip/selection re-derive + // from the FINAL surface so dependents stay coherent with overrides. + const surface = c('completion_menu_bg') ?? derived.completionBg + + // Re-mix the chip only when the skin authored its own surface; otherwise + // the derived value already carries the identity seeds (e.g. Hermes navy). + const activeRow = + c('completion_menu_current_bg') ?? + (c('completion_menu_bg') ? mix(surface, seeds.accent, 0.22) : derived.completionCurrentBg) + + const assembled: ThemeColors = { + ...derived, + muted: c('banner_dim') ?? derived.muted, + label: c('ui_label') ?? derived.label, + completionBg: surface, + completionCurrentBg: activeRow, + completionMetaBg: c('completion_menu_meta_bg') ?? surface, + completionMetaCurrentBg: c('completion_menu_meta_current_bg') ?? activeRow, + sessionLabel: c('session_label') ?? c('banner_dim') ?? derived.sessionLabel, + sessionBorder: c('session_border') ?? c('banner_dim') ?? derived.sessionBorder, + statusBg: c('status_bar_bg') ?? surface, + statusFg: c('status_bar_text') ?? derived.statusFg, + selectionBg: c('selection_bg') ?? c('completion_menu_current_bg') ?? derived.selectionBg + } + + // 4. Guard: contrast floors against the real background + fill polarity. + // Wrong-polarity fills fall back to the DERIVED value, which is + // polarity-correct by construction (mixed from the background itself). + // ANSI-limited light Apple Terminal keeps its bespoke ansi256 + // normalization (below) instead. const adapted = shouldNormalizeAnsiLightTheme(process.env, isLight) ? assembled - : adaptColorsToBackground(assembled, isLight, d.color, referenceBackground(isLight)) + : adaptColorsToBackground(assembled, isLight, derived, bg) return normalizeThemeForAnsiLightTerminal( { From 296303302d8586a6748fb7ba402903baf5300faa Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:36:42 -0500 Subject: [PATCH 05/22] =?UTF-8?q?perf(tui):=20skin=20switches=20stay=20hot?= =?UTF-8?q?=20=E2=80=94=20MCP=20gating,=20pooled=20reload,=20swap=20repain?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four responsiveness/hardening fixes surfaced by rapid /skin switching: config.get mtime now carries an mcp_rev hash so the TUI reloads MCP only when MCP-relevant config changed (cosmetic /skin writes cost seconds of reconnects before); reload.mcp runs on the RPC pool serialized by a lock (inline it froze the stdio reader for the duration of a flapping server's retry loop — config.set/complete.slash sat unread and the TUI appeared dead); theme swaps schedule one full repaint (incremental diffs after a recolor tear — stale cells keep the old palette, read as "shadows"); and OSC-11 pure-black answers are distrusted universally (unset-default fingerprint on xterm.js hosts and tmux). Parent EIO zombie fixed: a dead PTY made every render write throw once a second forever; exit after 5 consecutive dead-stream errors. --- tui_gateway/server.py | 49 ++++++++++++++++++-- ui-tui/src/app/createGatewayEventHandler.ts | 51 +++++++++++++++++---- ui-tui/src/app/useConfigSync.ts | 21 +++++++-- ui-tui/src/entry.tsx | 28 ++++++++++- ui-tui/src/gatewayTypes.ts | 3 ++ ui-tui/src/lib/color.ts | 17 +++++++ ui-tui/src/lib/themeBoot.ts | 11 ++++- ui-tui/src/theme.ts | 4 +- 8 files changed, 165 insertions(+), 19 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7941481b0bed..18ced36f1833 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3,6 +3,7 @@ import concurrent.futures import contextlib import contextvars import copy +import hashlib import inspect import json import logging @@ -226,6 +227,13 @@ _LONG_HANDLERS = frozenset( "pet.thumb", "learning.frames", "plugins.manage", + # reload.mcp shuts down and rediscovers every MCP server — with a + # flapping server (retry loops, connect timeouts up to 120s) that can + # block for minutes. Inline it froze the reader thread: config.set, + # complete.slash, prompt.submit all sat unread and the TUI appeared + # dead after a few skin switches. The handler serializes concurrent + # reloads via _mcp_reload_lock. + "reload.mcp", "process.list", "projects.discover_repos", "projects.record_repos", @@ -12674,11 +12682,24 @@ def _(rid, params: dict) -> dict: if key == "mtime": cfg_path = _hermes_home / "config.yaml" try: - return _ok( - rid, {"mtime": cfg_path.stat().st_mtime if cfg_path.exists() else 0} - ) + mtime = cfg_path.stat().st_mtime if cfg_path.exists() else 0 except Exception: return _ok(rid, {"mtime": 0}) + # Revision hash of the MCP-relevant config sections. The TUI's + # config-change poller uses it to reload MCP servers only when their + # config actually changed — a /skin or /statusbar write bumps mtime + # but must not cost a multi-second MCP reconnect. + try: + cfg = _load_cfg() + rev_src = json.dumps( + {"mcp": cfg.get("mcp"), "tools": cfg.get("tools")}, + sort_keys=True, + default=str, + ) + mcp_rev = hashlib.sha1(rev_src.encode()).hexdigest()[:12] + except Exception: + mcp_rev = "" + return _ok(rid, {"mtime": mtime, "mcp_rev": mcp_rev}) return _err(rid, 4002, f"unknown config key: {key}") @@ -12852,6 +12873,14 @@ def _(rid, params: dict) -> dict: return _err(rid, 5010, str(e)) +# reload.mcp runs on the RPC pool (see _LONG_HANDLERS) so a slow/flapping MCP +# server can't freeze the reader thread. Serialize reloads: overlapping +# shutdown+discover pairs from stacked config-change polls would interleave +# and leave the registry half-built. Piggyback rather than queue — a reload +# that arrives while one is running would just redo identical work. +_mcp_reload_lock = threading.Lock() + + @method("reload.mcp") def _(rid, params: dict) -> dict: session = _sessions.get(params.get("session_id", "")) @@ -12906,8 +12935,18 @@ def _(rid, params: dict) -> dict: from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools - shutdown_mcp_servers() - discover_mcp_tools() + if not _mcp_reload_lock.acquire(blocking=False): + # A reload is already in flight; wait for it to finish and reuse + # its result instead of tearing the freshly-built registry down. + with _mcp_reload_lock: + pass + return _ok(rid, {"status": "reloaded", "coalesced": True}) + + try: + shutdown_mcp_servers() + discover_mcp_tools() + finally: + _mcp_reload_lock.release() if session: agent = session["agent"] # Rebuild the cached agent's tool snapshot so the current session diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index f8746fadb9f7..7343d5d55e0b 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,6 +1,6 @@ import { execFile } from 'child_process' -import { onTerminalBackground } from '@hermes/ink' +import { forceRedraw, onTerminalBackground } from '@hermes/ink' import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js' import { STREAM_BATCH_MS } from '../config/timing.js' @@ -50,9 +50,42 @@ const themeForSkin = (s: GatewaySkin) => { // Patch the live theme AND persist it for the next launch's first frame // (flash-free boot — see lib/themeBoot.ts). +// +// The force-redraw is load-bearing: a theme swap recolors EVERYTHING, but the +// renderer's diff/blit cache treats layout-unchanged regions as reusable, so +// incremental repaints after a swap can tear — stale cells keep the previous +// palette (observed live: gold headers from the boot theme composited with +// slate chrome, dark status fills surviving on a light terminal, half- +// overwritten glyphs reading as "shadows"). One full clear+repaint after the +// new theme has rendered guarantees a coherent frame. Deferred ~2 frames so +// React + Ink flush the recolored tree first; skipping identical themes keeps +// the no-op resolution path (boot cache confirmed by detection) paint-free. +let lastCommittedTheme: Theme | null = null + const commitTheme = (theme: Theme) => { + const changed = lastCommittedTheme !== null && !themesEqual(lastCommittedTheme, theme) + + lastCommittedTheme = theme patchUiState({ theme }) writeBootTheme(theme, process.env.HERMES_TUI_BACKGROUND) + + if (changed) { + setTimeout(() => forceRedraw(process.stdout), 40).unref?.() + } +} + +const themesEqual = (a: Theme, b: Theme) => { + if (a === b) { + return true + } + + for (const key of Object.keys(a.color) as (keyof Theme['color'])[]) { + if (a.color[key] !== b.color[key]) { + return false + } + } + + return a.brand.name === b.brand.name && a.brand.prompt === b.brand.prompt && a.bannerLogo === b.bannerLogo && a.bannerHero === b.bannerHero } const applySkin = (s: GatewaySkin) => { @@ -122,13 +155,15 @@ export function syncThemeToTerminalBackground(): void { let resolved = false onTerminalBackground(hex => { - // xterm.js hosts (VS Code / Cursor) answer OSC 11 with #000000 when the - // editor theme sets no explicit terminal background — the renderer's - // unset DEFAULT, not the painted color (observed: pure black reported on - // a white terminal). A real vscode dark theme reports its actual surface - // (#1e1e1e etc.), so exactly-#000000 from a vscode host carries no - // signal: skip the cache and fall through to the OS-appearance inference. - if (hex === '#000000' && (process.env.TERM_PROGRAM ?? '') === 'vscode') { + // Exactly-#000000 is the "unset default" fingerprint, not a measurement: + // xterm.js reports it when the editor theme sets no terminal background + // (observed: pure black reported on a white Cursor terminal), and tmux + // answers OSC 11 with its own black fallback regardless of the outer + // terminal — and tmux also strips TERM_PROGRAM, so no host allow-list + // can catch it. Real dark themes report their actual surface (#1e1e1e, + // #282828, …). Distrusting pure black universally is safe: on a truly + // pure-black terminal the fall-through detection lands on dark anyway. + if (hex === '#000000') { return } diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 074e5b739197..af7eb8ab5ec0 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -245,6 +245,7 @@ export function useConfigSync({ sid }: UseConfigSyncOptions) { const mtimeRef = useRef(0) + const mcpRevRef = useRef('') useEffect(() => { if (!sid) { @@ -270,10 +271,12 @@ export function useConfigSync({ const id = setInterval(() => { quietRpc(gw, 'config.get', { key: 'mtime' }).then(r => { const next = Number(r?.mtime ?? 0) + const nextMcpRev = String(r?.mcp_rev ?? '') if (!mtimeRef.current) { if (next) { mtimeRef.current = next + mcpRevRef.current = nextMcpRev } return @@ -285,9 +288,21 @@ export function useConfigSync({ mtimeRef.current = next - quietRpc(gw, 'reload.mcp', { session_id: sid, confirm: true }).then( - r => r && turnController.pushActivity('MCP reloaded after config change') - ) + // Reload MCP only when the MCP-relevant config actually changed. + // Cosmetic writes (/skin, /statusbar, /theme) bump mtime constantly; + // reconnecting every MCP server for those costs seconds and made + // skin switching feel glacial. Older gateways don't send mcp_rev — + // fall back to reload-on-any-change there. + const mcpChanged = !nextMcpRev || nextMcpRev !== mcpRevRef.current + + mcpRevRef.current = nextMcpRev + + if (mcpChanged) { + quietRpc(gw, 'reload.mcp', { session_id: sid, confirm: true }).then( + r => r && turnController.pushActivity('MCP reloaded after config change') + ) + } + void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey) }) }, MTIME_POLL_MS) diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index f25be8091bdd..6f856cf57a9a 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -55,6 +55,8 @@ gw.start() const dumpNotice = (snap: MemorySnapshot, dump: HeapDumpResult | null) => `hermes-tui: ${snap.level} memory (${formatBytes(snap.heapUsed)}) — auto heap dump → ${dump?.heapPath ?? dump?.diagPath ?? '(failed)'}\n` +let consecutiveDeadStreamErrors = 0 + setupGracefulExit({ cleanups: [ () => { @@ -67,7 +69,31 @@ setupGracefulExit({ const message = err instanceof Error ? `${err.name}: ${err.message}\n${err.stack ?? ''}` : String(err) recordParentLifecycle(`${scope}: ${message.split('\n')[0]?.slice(0, 400) ?? ''}`) - process.stderr.write(`hermes-tui lifecycle ${scope}: ${message.slice(0, 2000)}\n`) + + // A dead PTY (terminal tab closed, SSH dropped without SIGHUP) turns every + // stdout/stderr write into EIO/EPIPE. Swallowing those here made the parent + // a zombie: Ink's render loop throws once a second, each throw lands back + // in this handler, and the crash log fills with `write EIO` forever while + // the gateway child keeps running. Bail out for real after a few in a row. + const code = (err as NodeJS.ErrnoException)?.code + + if (code === 'EIO' || code === 'EPIPE') { + if (++consecutiveDeadStreamErrors >= 5) { + recordParentLifecycle(`dead output stream (${code} x${consecutiveDeadStreamErrors}) → exiting`) + void gw.kill('dead-output-stream') + process.exit(1) + } + + return + } + + consecutiveDeadStreamErrors = 0 + + try { + process.stderr.write(`hermes-tui lifecycle ${scope}: ${message.slice(0, 2000)}\n`) + } catch { + // stderr may be the dead stream itself. + } }, onSignal: signal => { // The next line in the crash log is the child's `=== SIGTERM received ===` diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 521161840618..a4bbe04a8971 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -128,6 +128,9 @@ export interface ConfigFullResponse { } export interface ConfigMtimeResponse { + /** Revision hash of MCP-relevant config sections; reload MCP only when it + * changes (cosmetic writes like /skin must not trigger reconnects). */ + mcp_rev?: string mtime?: number } diff --git a/ui-tui/src/lib/color.ts b/ui-tui/src/lib/color.ts index 3c95076298c7..3d7bd080844b 100644 --- a/ui-tui/src/lib/color.ts +++ b/ui-tui/src/lib/color.ts @@ -265,6 +265,23 @@ export function retone(color: string, lightness: number, minSaturation = 0): str return toHex(fromHsl(h, Math.max(s, minSaturation), lightness)) } +/** Multiply HSL saturation by `factor` (clamped to 1), hue/lightness fixed. */ +export function boostSaturation(color: string, factor: number): string { + const rgb = parseColor(color) + + if (!rgb) { + return color + } + + const [h, s, l] = toHsl(rgb) + + if (s === 0) { + return color + } + + return toHex(fromHsl(h, Math.min(1, s * factor), l)) +} + /** * Chainable form for multi-step derivations, e.g. * `color(text).mix(bg, 0.35).mix(accent, 0.18).ensureContrast(bg, 2.8).hex()`. diff --git a/ui-tui/src/lib/themeBoot.ts b/ui-tui/src/lib/themeBoot.ts index 0471eaa2008e..6062ac2c103d 100644 --- a/ui-tui/src/lib/themeBoot.ts +++ b/ui-tui/src/lib/themeBoot.ts @@ -111,7 +111,16 @@ export function writeBootTheme(theme: Theme, background?: string): void { */ const boot = readBootTheme() -if (boot?.background && !process.env.HERMES_TUI_BACKGROUND && !process.env.HERMES_TUI_THEME && !process.env.HERMES_TUI_LIGHT) { +if ( + boot?.background && + // Never seed the untrusted "unset default" fingerprint — a cache written + // before the distrust rule existed must not poison this session's + // detection (it would also suppress the macOS-appearance fallback). + boot.background.toLowerCase() !== '#000000' && + !process.env.HERMES_TUI_BACKGROUND && + !process.env.HERMES_TUI_THEME && + !process.env.HERMES_TUI_LIGHT +) { process.env.HERMES_TUI_BACKGROUND = boot.background } diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 690b4aa84263..cb29f93f7e40 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -541,7 +541,9 @@ export function deriveTones(seeds: { const { accent, bg, primary, text } = seeds const isLight = (relativeLuminance(bg) ?? 0) > 0.5 const inkBlend = mix(primary, text, 0.27) - const surface = mix(bg, desaturate(accent, 0.35), isLight ? 0.045 : 0.09) + // Fill tint keeps most of the accent's chroma — a heavier desaturate here + // read as washed-out ("a little too desat") next to authored fills. + const surface = mix(bg, desaturate(accent, 0.15), isLight ? 0.045 : 0.09) return { muted: isLight ? inkBlend : desaturate(mix(accent, bg, 0.19), 0.16), From 28e05a3c85cdbec12fd4f02244ae8e8f5ef949dc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:36:59 -0500 Subject: [PATCH 06/22] fix(ui-tui): light mode renders the vivid palette RAW, not WCAG-darkened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pixel-sampled against the reference screenshots: the beloved classic light-mode look is the vivid authored golds rendered essentially raw (#FFD700 shows as bright #F5C242) — transparent terminal profiles apply no contrast lift of their own, and pre-darkening every foreground to WCAG 4.5 produced the reported mustard mud. Light-mode display floors become near-invisible rescues only (1.18 display / 1.6 semantic; dark keeps 1.45/2.2); the default skin ships a fills-only light_colors OVERLAY (polarity-flip the navy menu/status fills, foregrounds inherit the vivid colors), and themeForSkin merges polarity overlays instead of replacing. Palette audit reworked: base colors audited fully, overlays for valid keys and fill polarity. --- hermes_cli/skin_engine.py | 36 +++------- tests/hermes_cli/test_skin_palettes.py | 78 +++++++++++++++------ ui-tui/src/__tests__/theme.test.ts | 25 ++++--- ui-tui/src/app/createGatewayEventHandler.ts | 12 ++-- ui-tui/src/theme.ts | 75 ++++++++++++++------ 5 files changed, 144 insertions(+), 82 deletions(-) diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 679416b8971d..3a89554f131e 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -215,35 +215,21 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "shell_dollar": "#4dabf7", "voice_status_bg": "#1a1a2e", }, - # Hand-tuned light palette (mirrors the TUI's LIGHT_THEME golds). + # No paired light_colors. On a light (in practice transparent) terminal + # the beloved classic look is these vivid golds rendered RAW — xterm + # applies no contrast lift over a transparent bg, so #FFD700 shows as + # bright #F5C242, not a WCAG-darkened mustard. The TUI's display shim + # only flips fills to light polarity and rescues genuinely-invisible + # near-white text; the golds pass through untouched. A hand-authored + # dark "light palette" here is exactly what made it read as mud. "light_colors": { - "banner_border": "#7A4F1F", - "banner_title": "#8B6914", - "banner_accent": "#A0651C", - "banner_dim": "#7A5A0F", - "banner_text": "#3D2F13", - "ui_accent": "#A0651C", - "ui_label": "#7A5A0F", - "ui_ok": "#2E7D32", - "ui_error": "#C62828", - "ui_warn": "#E65100", - "prompt": "#2B2014", - "input_rule": "#7A4F1F", - "response_border": "#8B6914", - "status_bar_bg": "#F5F5F5", - "status_bar_text": "#333333", - "status_bar_strong": "#8B6914", - "status_bar_dim": "#8A8A8A", - "status_bar_good": "#2E7D32", - "status_bar_warn": "#8B6914", - "status_bar_bad": "#D84315", - "status_bar_critical": "#B71C1C", - "session_label": "#7A5A0F", - "session_border": "#7A5A0F", + # Fills only: on a light terminal the dark navy menu/status fills + # must flip to light. Foregrounds intentionally inherit the vivid + # `colors` above so they render raw. "completion_menu_bg": "#F5F5F5", "completion_menu_current_bg": "#E0D1BF", "selection_bg": "#D4E4F7", - "shell_dollar": "#1565C0", + "status_bar_bg": "#F5F5F5", "voice_status_bg": "#F5F5F5", }, "spinner": { diff --git a/tests/hermes_cli/test_skin_palettes.py b/tests/hermes_cli/test_skin_palettes.py index 519cc8de65c4..f69846ed4d46 100644 --- a/tests/hermes_cli/test_skin_palettes.py +++ b/tests/hermes_cli/test_skin_palettes.py @@ -126,41 +126,57 @@ def contrast(a: str, b: str) -> float: LIGHT_AUTHORED = frozenset({"daylight", "warm-lightmode"}) -def _palettes(): - """Yield (skin, palette_name, palette, is_light) for every built-in.""" +# A `light_colors`/`dark_colors` block is an OVERLAY on `colors`, not a full +# replacement — a skin may ship a fills-only light overlay (flip the dark navy +# menu/status fills to light) while its vivid foregrounds keep coming from +# `colors` and render raw. So only the base `colors` block is held to the +# completeness + full foreground-contrast contract; overlays are audited for +# valid keys and fill polarity only. +def _base_palettes(): + """Yield (skin, palette, is_light) for every built-in's base `colors`.""" for name, skin in _BUILTIN_SKINS.items(): - yield name, "colors", skin.get("colors", {}), name in LIGHT_AUTHORED + yield name, skin.get("colors", {}), name in LIGHT_AUTHORED + +def _overlays(): + """Yield (skin, block, palette, is_light) for every partial overlay block.""" + for name, skin in _BUILTIN_SKINS.items(): if skin.get("light_colors"): yield name, "light_colors", skin["light_colors"], True if skin.get("dark_colors"): yield name, "dark_colors", skin["dark_colors"], False -ALL_PALETTES = list(_palettes()) -PALETTE_IDS = [f"{skin}:{block}" for skin, block, _, _ in ALL_PALETTES] +BASE_PALETTES = list(_base_palettes()) +BASE_IDS = [f"{skin}:colors" for skin, _, _ in BASE_PALETTES] +OVERLAYS = list(_overlays()) +OVERLAY_IDS = [f"{skin}:{block}" for skin, block, _, _ in OVERLAYS] -@pytest.mark.parametrize(("skin", "block", "palette", "is_light"), ALL_PALETTES, ids=PALETTE_IDS) -def test_palette_is_complete(skin, block, palette, is_light): +@pytest.mark.parametrize(("skin", "palette", "is_light"), BASE_PALETTES, ids=BASE_IDS) +def test_base_palette_is_complete(skin, palette, is_light): missing = REQUIRED_KEYS - palette.keys() - assert not missing, f"{skin}.{block} missing keys: {sorted(missing)}" + assert not missing, f"{skin}.colors missing keys: {sorted(missing)}" -@pytest.mark.parametrize(("skin", "block", "palette", "is_light"), ALL_PALETTES, ids=PALETTE_IDS) -def test_palette_contrast_and_polarity(skin, block, palette, is_light): +@pytest.mark.parametrize(("skin", "palette", "is_light"), BASE_PALETTES, ids=BASE_IDS) +def test_base_palette_contrast_and_polarity(skin, palette, is_light): pole = LIGHT_POLE if is_light else DARK_POLE problems = [] - for key in STRONG_FG: - ratio = contrast(palette[key], pole) - if ratio < STRONG_MIN: - problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {STRONG_MIN} vs {pole}") + # Light-authored bases render on a light pole where the classic look is the + # vivid palette rendered RAW (transparent terminals apply no lift), so the + # firm STRONG/SOFT floors only apply to dark-authored bases on a dark pole. + if not is_light: + for key in STRONG_FG: + ratio = contrast(palette[key], pole) + if ratio < STRONG_MIN: + problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {STRONG_MIN} vs {pole}") - for key in SOFT_FG: - ratio = contrast(palette[key], pole) - if ratio < SOFT_MIN: - problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {SOFT_MIN} vs {pole}") + for key in SOFT_FG: + ratio = contrast(palette[key], pole) + if ratio < SOFT_MIN: + problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {SOFT_MIN} vs {pole}") status_bg = palette["status_bar_bg"] for key in ON_STATUS_BAR: @@ -169,13 +185,35 @@ def test_palette_contrast_and_polarity(skin, block, palette, is_light): if ratio < floor: problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {floor} vs status_bar_bg {status_bg}") - for key in FILLS: + _check_fills(palette, is_light, FILLS, problems) + _check_chip(palette, problems) + + assert not problems, f"{skin}.colors:\n " + "\n ".join(problems) + + +@pytest.mark.parametrize(("skin", "block", "palette", "is_light"), OVERLAYS, ids=OVERLAY_IDS) +def test_overlay_keys_and_fill_polarity(skin, block, palette, is_light): + unknown = palette.keys() - REQUIRED_KEYS + assert not unknown, f"{skin}.{block} has unknown keys: {sorted(unknown)}" + + problems = [] + _check_fills(palette, is_light, [k for k in FILLS if k in palette], problems) + if "completion_menu_current_bg" in palette and "completion_menu_bg" in palette: + _check_chip(palette, problems) + + assert not problems, f"{skin}.{block}:\n " + "\n ".join(problems) + + +def _check_fills(palette, is_light, keys, problems): + for key in keys: lum = luminance(palette[key]) if is_light and lum < 0.4: problems.append(f"{key}={palette[key]} is a dark fill (lum {lum:.2f}) in a light palette") if not is_light and lum > 0.35: problems.append(f"{key}={palette[key]} is a light fill (lum {lum:.2f}) in a dark palette") + +def _check_chip(palette, problems): # The selection chip must remain distinguishable from the menu surface. chip = contrast(palette["completion_menu_current_bg"], palette["completion_menu_bg"]) if chip < 1.15: @@ -183,5 +221,3 @@ def test_palette_contrast_and_polarity(skin, block, palette, is_light): f"completion_menu_current_bg={palette['completion_menu_current_bg']} " f"indistinguishable from completion_menu_bg (contrast {chip:.2f})" ) - - assert not problems, f"{skin}.{block}:\n " + "\n ".join(problems) diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index fe3ac5467795..c7a00c196329 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -307,9 +307,11 @@ describe('fromSkin', () => { const theme = fromSkin({ banner_text: '#FFF8DC' }, {}) // No ansi256 bucketing on truecolor terminals — a truly invisible cream - // (1.08:1 on white) still gets the display shim's gentle rescue. + // (1.08:1 on white) still gets the display shim's gentle light-mode rescue + // (floor 1.18: enough to make near-white text appear, not enough to crush + // the vivid golds into mud). expect(theme.color.text).toMatch(/^#[0-9a-f]{6}$/i) - expect(contrastRatio(theme.color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.45) + expect(contrastRatio(theme.color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.18) }) it('normalizes Apple Terminal names before matching', async () => { @@ -398,8 +400,10 @@ describe('derived tone ladder', () => { [dark.DARK_THEME.color.completionBg, '#1a1a2e', 'dark surface'], [dark.DARK_THEME.color.completionCurrentBg, '#333355', 'dark chip'], [dark.DARK_THEME.color.selectionBg, '#3a3a55', 'dark selection'], - [light.LIGHT_THEME.color.muted, '#7A5A0F', 'light muted'], - [light.LIGHT_THEME.color.statusFg, '#333333', 'light statusFg'], + // Light canon = liftForContrast(dark literal, white, 4.5): the exact + // colors xterm's minimumContrastRatio rendered on light hosts. + [light.LIGHT_THEME.color.muted, '#946C08', 'light muted'], + [light.LIGHT_THEME.color.statusFg, '#6F6F6F', 'light statusFg'], [light.LIGHT_THEME.color.completionBg, '#F5F5F5', 'light surface'], [light.LIGHT_THEME.color.completionCurrentBg, '#e0d1bf', 'light chip'], [light.LIGHT_THEME.color.selectionBg, '#D4E4F7', 'light selection'] @@ -457,14 +461,17 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => { expect(color.accent.toLowerCase()).toBe('#7eb8f6') expect(color.muted.toLowerCase()).toBe('#4b5563') - // Colors below the display floor get xterm's hue-preserving rescue. + // Light mode renders the authored palette essentially RAW: a transparent + // terminal (the common Cursor case) applies no contrast lift of its own, + // and the beloved classic look is the vivid palette, not a WCAG-darkened + // one. Foregrounds only clear the near-invisible floor (1.18). for (const key of ['text', 'prompt', 'accent', 'label', 'primary', 'muted', 'border'] as const) { - expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.45) + expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.18) } - // Semantic alert colors carry meaning — firmer floor. + // Semantic alert colors carry meaning — firmer floor, still gentle on light. for (const key of ['ok', 'error', 'warn', 'statusGood', 'statusCritical'] as const) { - expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(2.15) + expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.6) } // Background roles the skin never defined must be light-polarity fills, @@ -480,7 +487,7 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => { const { color } = fromSkin({ banner_text: '#FFF8DC' }, {}) expect(color.text.toLowerCase()).not.toBe('#fff8dc') - expect(contrastRatio(color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.45) + expect(contrastRatio(color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.18) // Multiplicative lift preserves channel ordering (warm stays warm). const [r, g, b] = [1, 3, 5].map(i => parseInt(color.text.slice(i, i + 2), 16)) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 7343d5d55e0b..1a20dd2473f5 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -39,11 +39,15 @@ const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready') let lastSkin: GatewaySkin | null = null const themeForSkin = (s: GatewaySkin) => { - // Prefer the skin's hand-tuned palette for the terminal's polarity - // (desktop colors/darkColors contract). Without a paired block, `colors` - // goes through fromSkin's automatic contrast adaptation instead. + // Polarity overrides OVERLAY the base palette, they don't replace it: a skin + // can ship a fills-only `light_colors` (flip the dark navy menu/status fills + // to light on a light terminal) while its vivid foreground golds keep coming + // from `colors` and render raw through fromSkin's shim. A full paired block + // still works — it just overrides every key it lists. const paired = detectLightMode() ? s.light_colors : s.dark_colors - const colors = paired && Object.keys(paired).length ? paired : (s.colors ?? {}) + + const colors = + paired && Object.keys(paired).length ? { ...(s.colors ?? {}), ...paired } : (s.colors ?? {}) return fromSkin(colors, s.branding ?? {}, s.banner_logo ?? '', s.banner_hero ?? '', s.tool_prefix ?? '', s.help_header ?? '') } diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index cb29f93f7e40..28ce5c0f14dc 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -340,21 +340,28 @@ export const DARK_SEEDS: ThemeSeeds = { } // Light-terminal seeds: darker golds/ambers that stay legible on white. +// The classic light-mode Hermes look was never hand-authored: for years the +// TUI emitted the DARK golds and hosts with xterm's minimumContrastRatio +// (Cursor defaults to 4.5) lifted them against white — hue and saturation +// kept, luminance clamped. These seeds are those exact lifts +// (liftForContrast(dark, '#ffffff', 4.5)), so hosts WITHOUT a contrast pass +// render the same thing Cursor always showed. Text/prompt stay ink — body +// copy historically rendered in the terminal's default near-black fg. export const LIGHT_SEEDS: ThemeSeeds = { - accent: '#A0651C', + accent: '#956E00', bg: '#ffffff', - border: '#7A4F1F', - error: '#C62828', - ok: '#2E7D32', - primary: '#8B6914', + border: '#A56628', + error: '#C14240', + ok: '#367E39', + primary: '#867000', prompt: '#2B2014', - shellDollar: '#1565C0', - statusBad: '#D84315', - statusCritical: '#B71C1C', - statusGood: '#2E7D32', - statusWarn: '#8B6914', + shellDollar: '#377BB3', + statusBad: '#A65A00', + statusCritical: '#B94D4D', + statusGood: '#5C7A5C', + statusWarn: '#867000', text: '#3D2F13', - warn: '#E65100' + warn: '#956115' } export const DARK_THEME: Theme = { @@ -401,8 +408,20 @@ export const LIGHT_THEME: Theme = { // The lift itself is xterm.js's own multiplicative algorithm // (liftForContrast), so on hosts that run their own minimumContrastRatio the // two adjustments agree instead of fighting. +// Foreground floors are polarity-aware. On a DARK background the authored +// palette is already bright, so a modest floor only rescues the rare dark +// tone. On a LIGHT background — which in practice means a TRANSPARENT Cursor/ +// terminal window compositing over a light editor, where xterm applies NO +// contrast lift of its own (there is no solid bg to measure against) — the +// beloved classic look is the authored palette rendered essentially RAW: +// vivid #FFD700 gold (~1.36:1), not a WCAG-darkened mustard. So the light +// floor is a near-invisible rescue only (catches cream #FFF8DC at 1.08 but +// leaves the golds untouched). Pixel-sampled target: #F5C242 (L61 S90), +// which the previous 1.45 floor crushed to #867000 (L26) — the reported mud. const DISPLAY_MIN_CONTRAST = 1.45 const SEMANTIC_MIN_CONTRAST = 2.2 +const LIGHT_DISPLAY_MIN_CONTRAST = 1.18 +const LIGHT_SEMANTIC_MIN_CONTRAST = 1.6 const DISPLAY_FOREGROUNDS: readonly (keyof ThemeColors)[] = [ 'primary', @@ -444,13 +463,15 @@ const DARK_BG_MAX_LUMINANCE = 0.35 function adaptColorsToBackground(colors: ThemeColors, isLight: boolean, base: ThemeColors, bg: string): ThemeColors { const out = { ...colors } + const displayFloor = isLight ? LIGHT_DISPLAY_MIN_CONTRAST : DISPLAY_MIN_CONTRAST + const semanticFloor = isLight ? LIGHT_SEMANTIC_MIN_CONTRAST : SEMANTIC_MIN_CONTRAST for (const key of DISPLAY_FOREGROUNDS) { - out[key] = liftForContrast(out[key], bg, DISPLAY_MIN_CONTRAST) + out[key] = liftForContrast(out[key], bg, displayFloor) } for (const key of SEMANTIC_FOREGROUNDS) { - out[key] = liftForContrast(out[key], bg, SEMANTIC_MIN_CONTRAST) + out[key] = liftForContrast(out[key], bg, semanticFloor) } for (const key of ADAPTIVE_BACKGROUNDS) { @@ -521,11 +542,16 @@ export interface ThemeTones { * dark muted #CC9B1F ≈ desaturate(mix(accent, bg, .19), .16) (err 3) * dark label #DAA520 ≈ desaturate(mix(accent, bg, .13), .16) (err 3) * dark status #C0C0C0 = grayOf(mix(text, bg, .24)) (err 0) - * light muted/label #7A5A0F ≈ mix(primary, text, .27) (err 5) - * light status #333333 = grayOf(mix(text, bg, .01)) (err 1) + * light muted #946C08 ≈ desaturate(accent, .05) (err 2) + * light label #8E6B13 ≈ desaturate(mix(accent, text, .03), .15) (err 2) + * light status #6F6F6F = grayOf(mix(text, bg, .30)) (err 1) * light surface #F5F5F5 ≈ bg + softened accent (err 5) - * light chip #E0D1BF = mix(surface, accent, .25) (err 3) - * light selection #D4E4F7 ≈ mix(bg, shellDollar, .17) (err 3) + * light chip #E0D1BF = mix(surface, accent, .25) (err 8) + * light selection #D4E4F7 ≈ mix(bg, shellDollar, .20) (err 7) + * + * The light targets are the LIFT CANON: liftForContrast(dark literal, + * white, 4.5) — what xterm's minimumContrastRatio showed on light hosts + * for years — not hand-picked browns (those read as desaturated mud). * * The classic dark navy fills (#1a1a2e/#333355/#3a3a55) are IRREDUCIBLE from * gold seeds — the search bottoms out at gray, err 10–17 — so they remain @@ -538,21 +564,24 @@ export function deriveTones(seeds: { shellDollar?: string text: string }): ThemeTones { - const { accent, bg, primary, text } = seeds + const { accent, bg, text } = seeds const isLight = (relativeLuminance(bg) ?? 0) > 0.5 - const inkBlend = mix(primary, text, 0.27) // Fill tint keeps most of the accent's chroma — a heavier desaturate here // read as washed-out ("a little too desat") next to authored fills. const surface = mix(bg, desaturate(accent, 0.15), isLight ? 0.045 : 0.09) return { - muted: isLight ? inkBlend : desaturate(mix(accent, bg, 0.19), 0.16), - label: isLight ? inkBlend : desaturate(mix(accent, bg, 0.13), 0.16), - statusFg: grayOf(mix(text, bg, isLight ? 0.01 : 0.24)), + // Light knobs are fitted to the lift canon (xterm minimumContrastRatio + // 4.5 of the classic dark golds against white — see LIGHT_SEEDS), not + // to ink blends: muted #946C08 ≈ desat(accent .05), label #8E6B13 ≈ + // desat(mix(accent, text, .03), .15), statusFg #6F6F6F ≈ gray 30% lift. + muted: isLight ? desaturate(accent, 0.05) : desaturate(mix(accent, bg, 0.19), 0.16), + label: isLight ? desaturate(mix(accent, text, 0.03), 0.15) : desaturate(mix(accent, bg, 0.13), 0.16), + statusFg: grayOf(mix(text, bg, isLight ? 0.3 : 0.24)), surface, activeRow: mix(surface, accent, 0.25), selection: - isLight && seeds.shellDollar ? mix(bg, seeds.shellDollar, 0.17) : mix(surface, accent, 0.28), + isLight && seeds.shellDollar ? mix(bg, seeds.shellDollar, 0.2) : mix(surface, accent, 0.28), border: mix(accent, bg, 0.25) } } From 6929f5f71f36dd12bec13ea6719de679d07b58a3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:37:00 -0500 Subject: [PATCH 07/22] fix(ui-tui): eradicate every transparent-terminal black-slab trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On terminal.background #00000000 xterm paints "drawn blank" and attribute-styled cells against an opaque black RGB the user never sees elsewhere. Verified by PTY byte capture + stateful SGR replay that we emit no black backgrounds — then removed every trigger: the banner's opaque space-fills, the scrollbar's non-scrollable space column and SGR-dim track, the placeholder's SGR inverse cursor and dim fallback, and the bold full-width banner rule. Chrome styling is now explicit truecolor only: scrollbar thumb rides primary (accent on hover/drag) over a blended track, the placeholder cursor is a theme-colored chip (48;2 bg + luminance-picked ink), hints always carry an explicit 38;2 foreground. --- ui-tui/src/components/appChrome.tsx | 33 ++++++++++------- ui-tui/src/components/appLayout.tsx | 7 +++- ui-tui/src/components/branding.tsx | 22 ++++++++--- ui-tui/src/components/overlayScrollbar.tsx | 24 +++++------- ui-tui/src/components/textInput.tsx | 43 ++++++++++++++-------- 5 files changed, 79 insertions(+), 50 deletions(-) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index cd2d4a9ba9de..2f6eb417fc3d 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -11,6 +11,7 @@ import { FACES } from '../content/faces.js' import { VERBS } from '../content/verbs.js' import { fmtDuration } from '../domain/messages.js' import { stickyPromptFromViewport } from '../domain/viewport.js' +import { mix } from '../lib/color.js' import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js' import { fmtK } from '../lib/text.js' import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js' @@ -753,8 +754,14 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp const travel = Math.max(1, vp - thumb) const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0 - const thumbColor = grab !== null ? t.color.primary : hover ? t.color.accent : t.color.border - const trackColor = hover ? t.color.border : t.color.muted + // Thumb rides in the theme's BASE color (primary), brightening to accent + // while hovered/dragged. The track recedes via an EXPLICIT blend toward the + // theme surface — never the SGR dim attribute: dim is terminal-interpreted, + // and on transparent profiles (terminal.background #00000000) xterm renders + // dim cells as half-bright glyphs on an opaque BLACK cell background. That + // was the "black bar / black thumb" on the right edge. + const thumbColor = grab !== null || hover ? t.color.accent : t.color.primary + const trackColor = mix(hover ? t.color.border : t.color.muted, t.color.completionBg, hover ? 0.25 : 0.55) const jump = (row: number, offset: number) => { if (!s || !scrollable) { @@ -786,24 +793,24 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) }} width={1} > - {!scrollable ? ( - - {' \n'.repeat(Math.max(0, vp - 1))}{' '} - - ) : ( + {!scrollable ? null : ( // Nothing to scroll → draw nothing. The outer + // width={1} Box still reserves the column so the transcript width + // doesn't jump when content grows scrollable. Previously this drew a + // full-height column of SPACES; on a transparent terminal + // (terminal.background #00000000) those drawn-blank cells composite to + // a black bar (the "big black area" by the scrollbar) — same class as + // the banner's removed opaque fill. A `│` track only appears when + // there's actually something to scroll, which is what reads as "makes + // sense." <> {thumbTop > 0 ? ( - - {`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`} - + {`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`} ) : null} {thumb > 0 ? ( {`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`} ) : null} {vp - thumbTop - thumb > 0 ? ( - - {`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`} - + {`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`} ) : null} )} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index fa3e4952dc3f..14d41ac69839 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -17,6 +17,7 @@ import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' +import { mix } from '../lib/color.js' import { PerfPane } from '../lib/perfPane.js' import { composerPromptText } from '../lib/prompt.js' @@ -418,7 +419,11 @@ const ComposerPane = memo(function ComposerPane({ onPaste={composer.handleTextPaste} onSubmit={composer.submit} placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''} - placeholderColor={ui.theme.color.muted} + // Faint = an EXPLICIT color blended toward the theme surface, + // never SGR dim: dim is terminal-interpreted, and on + // transparent profiles it renders as a black slab. The + // surface tracks polarity, so the hint recedes on both. + placeholderColor={mix(ui.theme.color.muted, ui.theme.color.completionBg, 0.45)} value={composer.input} voiceRecordKey={composer.voiceRecordKey} /> diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 486e4d0b96a4..9f5d191cfd61 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -30,8 +30,13 @@ function InlineLoader({ label, t }: { label: string; t: Theme }) { } export function ArtLines({ lines }: { lines: [string, string][] }) { + // No `opaque`: the banner is top-level content with nothing behind it, so + // it never needs the opaque space-fill (that's for absolute overlays). On a + // transparent terminal (terminal.background #00000000) the fill's "default + // background" spaces composite to black bars instead of the intended + // see-through — the reported ugly banner. Glyphs paint fine on their own. return ( - + {lines.map(([c, text], i) => ( {text} @@ -74,11 +79,18 @@ function CompactBanner({ cols, t }: { cols: number; t: Theme }) { // -4 keeps a margin so exact-edge rows don't trip terminal pending-wrap. const w = Math.max(28, cols - 4) + // No `opaque` (see ArtLines): the dashed rules are glyphs and the tagline's + // centering spaces carry the text's own fg style, so every cell paints with + // a real see-through background. The opaque fill was writing default-bg + // spaces that a transparent terminal renders as black bars. + // NOT bold: on Cursor's transparent-background terminal, a full-width run + // of BOLD box-drawing dashes renders with an opaque black cell background + // (the plain-dash rule right below renders clean — pixel-diffed live; the + // only stylistic delta was bold). Bold on short label runs is fine; bold on + // full-width box-drawing rows is what triggers the slab. return ( - - - {ruleIn(t.brand.name, w)} - + + {ruleIn(t.brand.name, w)} {centerIn(TAG_FULL, w)} {'─'.repeat(w)} diff --git a/ui-tui/src/components/overlayScrollbar.tsx b/ui-tui/src/components/overlayScrollbar.tsx index 9b5ba54ffbc7..56e93f3f23e5 100644 --- a/ui-tui/src/components/overlayScrollbar.tsx +++ b/ui-tui/src/components/overlayScrollbar.tsx @@ -1,6 +1,7 @@ import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' import { type RefObject, useState } from 'react' +import { mix } from '../lib/color.js' import type { Theme } from '../theme.js' /** @@ -39,8 +40,11 @@ export function OverlayScrollbar({ const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}│` : '') const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}┃` - const thumbColor = grab !== null ? t.color.primary : t.color.accent - const trackColor = hover ? t.color.border : t.color.muted + // Same scheme as TranscriptScrollbar: base-color thumb, accent on interact, + // track receded via explicit blend (SGR dim renders as a black slab on + // transparent terminals — see TranscriptScrollbar). + const thumbColor = grab !== null || hover ? t.color.accent : t.color.primary + const trackColor = mix(hover ? t.color.border : t.color.muted, t.color.completionBg, hover ? 0.25 : 0.55) const jump = (row: number, offset: number) => { if (!s || !scrollable) { @@ -68,24 +72,14 @@ export function OverlayScrollbar({ width={1} > {!scrollable ? ( - - {vBar(vp)} - + {vBar(vp)} ) : ( <> - {thumbTop > 0 ? ( - - {vBar(thumbTop)} - - ) : null} + {thumbTop > 0 ? {vBar(thumbTop)} : null} {thumbBody} - {below > 0 ? ( - - {vBar(below)} - - ) : null} + {below > 0 ? {vBar(below)} : null} )} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 81f555db3033..4c7ea4f21d63 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -31,8 +31,6 @@ const { Box, Text, useStdin, useInput, useStdout, stringWidth, useCursorAdvance, const ESC = '\x1b' const INV = `${ESC}[7m` const INV_OFF = `${ESC}[27m` -const DIM = `${ESC}[2m` -const DIM_OFF = `${ESC}[22m` const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`) const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g') @@ -41,23 +39,39 @@ const MULTI_CLICK_MS = 500 type MinimalEnv = Record const invert = (s: string) => INV + s + INV_OFF -const dim = (s: string) => DIM + s + DIM_OFF -/** Truecolor foreground wrap; falls back to SGR dim when no hex is given so - * the placeholder can follow the THEME's muted color instead of whatever the - * terminal's default-foreground dim happens to look like. */ +/** Truecolor foreground wrap. Always emits an EXPLICIT color — never SGR dim + * and never inverse: both are terminal-interpreted relative to the default + * fg/bg, and on transparent profiles (terminal.background #00000000) they + * composite against a black RGB the user never sees elsewhere, rendering the + * hint as a black slab. A fixed mid-gray fallback is deterministic on every + * host when no theme color is provided. */ +const HINT_FALLBACK = '#808080' + const colorizeHint = (s: string, hex?: string) => { - const m = hex ? /^#([0-9a-f]{6})$/i.exec(hex) : null - - if (!m) { - return dim(s) - } - + const m = /^#([0-9a-f]{6})$/i.exec(hex ?? '') ?? /^#([0-9a-f]{6})$/i.exec(HINT_FALLBACK)! const n = parseInt(m[1]!, 16) return `${ESC}[38;2;${(n >> 16) & 0xff};${(n >> 8) & 0xff};${n & 0xff}m${s}${ESC}[39m` } +/** Synthetic placeholder cursor: an explicit-truecolor chip (hint-colored + * block, luminance-picked ink) instead of SGR inverse. Inverse swaps + * against the terminal's DEFAULT fg/bg, which on transparent profiles is a + * black RGB the user never sees — the "cursor" rendered as a black slab. */ +const hintCursorCell = (ch: string, hex?: string) => { + const m = /^#([0-9a-f]{6})$/i.exec(hex ?? '') ?? /^#([0-9a-f]{6})$/i.exec(HINT_FALLBACK)! + const n = parseInt(m[1]!, 16) + + const r = (n >> 16) & 0xff, + g = (n >> 8) & 0xff, + b = n & 0xff + + const ink = 0.2126 * r + 0.7152 * g + 0.0722 * b > 140 ? '0;0;0' : '255;255;255' + + return `${ESC}[48;2;${r};${g};${b}m${ESC}[38;2;${ink}m${ch}${ESC}[39m${ESC}[49m` +} + let _seg: Intl.Segmenter | null = null const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' })) const STOP_CACHE_MAX = 32 @@ -658,10 +672,7 @@ export function TextInput({ } if (!display && placeholder) { - return ( - colorizeHint(invert(placeholder[0] ?? ' '), placeholderColor) + - colorizeHint(placeholder.slice(1), placeholderColor) - ) + return hintCursorCell(placeholder[0] ?? ' ', placeholderColor) + colorizeHint(placeholder.slice(1), placeholderColor) } if (selected) { From 3c135abea5275202a7677e57aa17946700eafe3e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:37:14 -0500 Subject: [PATCH 08/22] fix(ui-tui): session-panel hierarchy + polarity-proof placeholder tone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool/skill rows rendered labels in muted and member lists in text — which inverts per skin (muted is the strong family tone on gold skins but the weak gray on blue ones; slate/poseidon read backwards). Labels now lead in the theme's label tone, values recede via an explicit fade toward the surface; audited across all 9 built-ins x both poles. The composer placeholder lands on muted — the "(and N more toolsets…)" tone — a mid-luminance family color that reads receded on both poles even when polarity detection is wrong. --- ui-tui/src/components/appLayout.tsx | 12 ++++++------ ui-tui/src/components/branding.tsx | 15 +++++++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 14d41ac69839..8befa68e54fa 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -17,7 +17,6 @@ import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' -import { mix } from '../lib/color.js' import { PerfPane } from '../lib/perfPane.js' import { composerPromptText } from '../lib/prompt.js' @@ -419,11 +418,12 @@ const ComposerPane = memo(function ComposerPane({ onPaste={composer.handleTextPaste} onSubmit={composer.submit} placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''} - // Faint = an EXPLICIT color blended toward the theme surface, - // never SGR dim: dim is terminal-interpreted, and on - // transparent profiles it renders as a black slab. The - // surface tracks polarity, so the hint recedes on both. - placeholderColor={mix(ui.theme.color.muted, ui.theme.color.completionBg, 0.45)} + // Exactly the "(and N more toolsets…)" tone. `muted` is a + // MID-luminance family tone, so it reads receded on both + // poles even when polarity detection is wrong (transparent + // terminals lie about their background); anything blended + // toward the resolved surface inherits that wrong polarity. + placeholderColor={ui.theme.color.muted} value={composer.input} voiceRecordKey={composer.voiceRecordKey} /> diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 9f5d191cfd61..513d5a57624d 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import unicodeSpinners from 'unicode-animations' import { artWidth, caduceus, CADUCEUS_WIDTH, logo, LOGO_WIDTH } from '../banner.js' +import { mix } from '../lib/color.js' import { flat } from '../lib/text.js' import type { Theme } from '../theme.js' import type { PanelSection, SessionInfo } from '../types.js' @@ -231,6 +232,12 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { const lineBudget = Math.max(12, w - 2) const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s) + // Hierarchy: category labels lead in the theme's label tone; member lists + // recede in body-text faded toward the surface. Skin-relative on purpose — + // `muted` is the STRONG family tone on gold skins but the weak one on blue + // skins, so hardcoding it flips the hierarchy per skin (slate/poseidon bug). + const listFade = mix(t.color.text, t.color.completionBg, 0.5) + // ── Local collapse state for each section ── const [toolsOpen, setToolsOpen] = useState(true) const [skillsOpen, setSkillsOpen] = useState(false) @@ -272,8 +279,8 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { <> {shown.map(([k, vs]) => ( - {strip(k)}: - {truncLine(strip(k) + ': ', vs)} + {strip(k)}: + {truncLine(strip(k) + ': ', vs)} ))} {overflow > 0 && (and {overflow} more categories…)} @@ -299,8 +306,8 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { <> {shown.map(([k, vs]) => ( - {strip(k)}: - {truncLine(strip(k) + ': ', vs)} + {strip(k)}: + {truncLine(strip(k) + ': ', vs)} ))} {overflow > 0 && (and {overflow} more toolsets…)} From 4426d57a8420aaed0984e28b8b469aff94a8e5ed Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:37:14 -0500 Subject: [PATCH 09/22] feat(ui-tui): OSC-10 foreground polarity tiebreaker for transparent terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transparent profiles make OSC-11 useless (xterm reports the unset default, pure black, regardless of the composited surface) so polarity detection lagged editor theme flips. OSC-10 reports the theme's REAL foreground on those hosts — its luminance reveals the pole. hermes-ink grows a foreground slot (shared reportedColorSlot factory), App.tsx queries both in the startup batch (background first so a trusted answer wins without churn), and the app commits an inferred pole only when the background was distrusted AND the foreground is decisive (bright=dark theme, dark=light; mid-grays and #000/#fff defaults commit nothing). User pins still outrank. --- .../packages/hermes-ink/src/entry-exports.ts | 9 +- .../hermes-ink/src/ink/components/App.tsx | 66 ++++++++----- .../src/ink/terminal-background.test.ts | 14 +++ .../packages/hermes-ink/src/ink/terminal.ts | 96 ++++++++++++------- .../createGatewayEventHandler.test.ts | 14 +++ ui-tui/src/app/createGatewayEventHandler.ts | 53 +++++++++- ui-tui/src/types/hermes-ink.d.ts | 2 + 7 files changed, 195 insertions(+), 59 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 57968a60e630..49bbf21179f1 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -26,7 +26,14 @@ export { default as measureElement } from './ink/measure-element.js' export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js' export { createRoot, forceRedraw, default as render, renderSync } from './ink/root.js' export { stringWidth } from './ink/stringWidth.js' -export { isXtermJs, onTerminalBackground, parseOscColor, terminalBackgroundHex } from './ink/terminal.js' +export { + isXtermJs, + onTerminalBackground, + onTerminalForeground, + parseOscColor, + terminalBackgroundHex, + terminalForegroundHex +} from './ink/terminal.js' export type { MouseTrackingMode } from './ink/termio/dec.js' export { wrapAnsi } from './ink/wrapAnsi.js' diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx index 8602a5ba5e43..3d732b0fb87f 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -22,7 +22,14 @@ import reconciler from '../reconciler.js' import { clearSelection, finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js' import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js' import { decrqm, oscColor, TerminalQuerier, xtversion } from '../terminal-querier.js' -import { isXtermJs, parseOscColor, setTerminalBackgroundHex, setXtversionName, supportsExtendedKeys } from '../terminal.js' +import { + isXtermJs, + parseOscColor, + setTerminalBackgroundHex, + setTerminalForegroundHex, + setXtversionName, + supportsExtendedKeys +} from '../terminal.js' import { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, @@ -336,28 +343,43 @@ export default class App extends PureComponent { // init sequence completes — avoids interleaving with alt-screen/mouse // tracking enable writes that may happen in the same render cycle. setImmediate(() => { - // OSC 11 rides the same batch: the terminal's actual background - // color drives light/dark theme detection where env heuristics - // (COLORFGBG, TERM_PROGRAM) are blind — notably xterm.js hosts. - void Promise.all([this.querier.send(xtversion()), this.querier.send(oscColor(11)), this.querier.flush()]).then( - ([r, bg]) => { - if (r) { - setXtversionName(r.name) - logForDebugging(`XTVERSION: terminal identified as "${r.name}"`) - } else { - logForDebugging('XTVERSION: no reply (terminal ignored query)') - } - - const bgHex = bg ? parseOscColor(bg.data) : undefined - - if (bgHex) { - setTerminalBackgroundHex(bgHex) - logForDebugging(`OSC11: terminal background is ${bgHex}`) - } else { - logForDebugging('OSC11: no reply (terminal ignored query)') - } + // OSC 11 + OSC 10 ride the same batch: the terminal's actual + // background drives light/dark theme detection where env heuristics + // (COLORFGBG, TERM_PROGRAM) are blind — notably xterm.js hosts. The + // FOREGROUND is the polarity tiebreaker for transparent profiles: + // those report the unset-default background (pure black) but the + // theme's real foreground, whose luminance reveals the pole. + void Promise.all([ + this.querier.send(xtversion()), + this.querier.send(oscColor(11)), + this.querier.send(oscColor(10)), + this.querier.flush() + ]).then(([r, bg, fg]) => { + if (r) { + setXtversionName(r.name) + logForDebugging(`XTVERSION: terminal identified as "${r.name}"`) + } else { + logForDebugging('XTVERSION: no reply (terminal ignored query)') } - ) + + const bgHex = bg ? parseOscColor(bg.data) : undefined + const fgHex = fg ? parseOscColor(fg.data) : undefined + + // Background first: a trusted OSC-11 answer settles polarity + // outright, so the foreground listener (the transparent-profile + // tiebreaker) sees it already resolved and stays silent. + if (bgHex) { + setTerminalBackgroundHex(bgHex) + logForDebugging(`OSC11: terminal background is ${bgHex}`) + } else { + logForDebugging('OSC11: no reply (terminal ignored query)') + } + + if (fgHex) { + setTerminalForegroundHex(fgHex) + logForDebugging(`OSC10: terminal foreground is ${fgHex}`) + } + }) }) // Re-assert mouse tracking on raw-mode re-entry. diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts b/ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts index 0cd1d0a9fef1..54c6286feeba 100644 --- a/ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts @@ -75,4 +75,18 @@ describe('terminal background storage', () => { t.setTerminalBackgroundHex('#000000') expect(t.terminalBackgroundHex()).toBe('#ffffff') }) + + it('foreground (OSC 10) is an independent slot with the same semantics', async () => { + const t = await freshTerminal() + const seen: string[] = [] + + t.onTerminalForeground(hex => seen.push(hex)) + t.setTerminalForegroundHex('#cccccc') + t.setTerminalForegroundHex('#000000') + + expect(seen).toEqual(['#cccccc']) + expect(t.terminalForegroundHex()).toBe('#cccccc') + // The background slot is untouched by foreground writes. + expect(t.terminalBackgroundHex()).toBeUndefined() + }) }) diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal.ts b/ui-tui/packages/hermes-ink/src/ink/terminal.ts index e3318e0fded4..5a3ae3906d05 100644 --- a/ui-tui/packages/hermes-ink/src/ink/terminal.ts +++ b/ui-tui/packages/hermes-ink/src/ink/terminal.ts @@ -172,50 +172,80 @@ export function needsAltScreenResizeScrollbackClear(env: NodeJS.ProcessEnv = pro return (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal' } -// -- OSC-11-detected terminal background (populated async at startup) -- +// -- OSC-detected terminal colors (populated async at startup) -- // // Env heuristics (COLORFGBG, TERM_PROGRAM allow-lists) can't see the actual -// terminal background — xterm.js hosts (VS Code / Cursor) set neither, so a +// terminal colors — xterm.js hosts (VS Code / Cursor) set neither, so a // light-themed editor terminal reads as "dark" and gets an unreadable -// palette. OSC 11 asks the terminal directly; App.tsx fires the query in the -// same startup batch as XTVERSION and calls setTerminalBackgroundHex() when -// the reply lands. Readers treat undefined as "not yet known / unsupported". +// palette. OSC 11 (background) and OSC 10 (foreground) ask the terminal +// directly; App.tsx fires both in the same startup batch as XTVERSION. +// The foreground matters because transparent profiles LIE about the +// background (xterm reports the unset default, pure black) while reporting +// the theme's real foreground — its luminance is the only trustworthy +// polarity signal on such hosts. Readers treat undefined as "not yet +// known / unsupported". -let terminalBackground: string | undefined -const terminalBackgroundListeners = new Set<(hex: string) => void>() - -/** Record the OSC 11 response. First writer wins (defend against re-probe). */ -export function setTerminalBackgroundHex(hex: string): void { - if (terminalBackground !== undefined) { - return - } - - terminalBackground = hex - - for (const listener of terminalBackgroundListeners) { - listener(hex) - } - - terminalBackgroundListeners.clear() +interface ReportedColorSlot { + set(hex: string): void + get(): string | undefined + on(listener: (hex: string) => void): void } +function reportedColorSlot(): ReportedColorSlot { + let value: string | undefined + const listeners = new Set<(hex: string) => void>() + + return { + // First writer wins (defend against re-probe). + set(hex) { + if (value !== undefined) { + return + } + + value = hex + + for (const listener of listeners) { + listener(hex) + } + + listeners.clear() + }, + get: () => value, + // Fires immediately when already known, otherwise once on the reply. + on(listener) { + if (value !== undefined) { + listener(value) + + return + } + + listeners.add(listener) + } + } +} + +const background = reportedColorSlot() +const foreground = reportedColorSlot() + +/** Record the OSC 11 response. */ +export const setTerminalBackgroundHex = (hex: string): void => background.set(hex) + /** The terminal's reported background as `#rrggbb`, or undefined if the * reply hasn't arrived (or the terminal ignored the query). */ -export function terminalBackgroundHex(): string | undefined { - return terminalBackground -} +export const terminalBackgroundHex = (): string | undefined => background.get() -/** Subscribe to the background color. Fires immediately when already known, - * otherwise once when the OSC 11 reply arrives. */ -export function onTerminalBackground(listener: (hex: string) => void): void { - if (terminalBackground !== undefined) { - listener(terminalBackground) +/** Subscribe to the background color. */ +export const onTerminalBackground = (listener: (hex: string) => void): void => background.on(listener) - return - } +/** Record the OSC 10 response. */ +export const setTerminalForegroundHex = (hex: string): void => foreground.set(hex) - terminalBackgroundListeners.add(listener) -} +/** The terminal's reported foreground as `#rrggbb`, or undefined if the + * reply hasn't arrived (or the terminal ignored the query). */ +export const terminalForegroundHex = (): string | undefined => foreground.get() + +/** Subscribe to the foreground color. */ +export const onTerminalForeground = (listener: (hex: string) => void): void => foreground.on(listener) /** * Parse an OSC color reply payload into `#rrggbb`. diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index ff584695a8f2..5a4b75db7cbe 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -743,6 +743,20 @@ describe('createGatewayEventHandler', () => { vi.unstubAllEnvs() }) + it('infers polarity from the OSC-10 foreground only when the answer is decisive', async () => { + const { polarityBackgroundFromForeground } = await import('../app/createGatewayEventHandler.js') + + // Bright foreground = dark theme; dark foreground = light theme. + expect(polarityBackgroundFromForeground('#cccccc')).toBe('#1e1e1e') + expect(polarityBackgroundFromForeground('#333333')).toBe('#ffffff') + + // Unset-default fingerprints and ambiguous mid-grays commit nothing. + expect(polarityBackgroundFromForeground('#000000')).toBeUndefined() + expect(polarityBackgroundFromForeground('#ffffff')).toBeUndefined() + expect(polarityBackgroundFromForeground('#808080')).toBeUndefined() + expect(polarityBackgroundFromForeground('not-a-color')).toBeUndefined() + }) + it('on gateway.ready with no STARTUP_RESUME_ID and auto_resume off, forges a new session', async () => { const appended: Msg[] = [] const newSession = vi.fn() diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 1a20dd2473f5..3ba6558b5d70 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,6 +1,6 @@ import { execFile } from 'child_process' -import { forceRedraw, onTerminalBackground } from '@hermes/ink' +import { forceRedraw, onTerminalBackground, onTerminalForeground } from '@hermes/ink' import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js' import { STREAM_BATCH_MS } from '../config/timing.js' @@ -13,6 +13,7 @@ import type { GatewaySkin, SessionMostRecentResponse } from '../gatewayTypes.js' +import { relativeLuminance } from '../lib/color.js' import { isTodoDone } from '../lib/liveProgress.js' import { openExternalUrl } from '../lib/openExternalUrl.js' import { rpcErrorMessage } from '../lib/rpc.js' @@ -149,6 +150,31 @@ let themeBackgroundSyncStarted = false * HERMES_TUI_LIGHT / HERMES_TUI_THEME overrides still win inside * detectLightMode, so users can pin a mode regardless of the probe. */ +/** Infer the terminal's polarity from its reported FOREGROUND (OSC 10). + * Transparent profiles lie about the background (unset default = pure + * black) but report the theme's real foreground — a bright foreground + * means a dark theme and vice versa. Returns a representative background + * for the inferred pole, or undefined when the answer is unusable + * (mid-gray foregrounds are ambiguous; #000000/#ffffff can be unset + * defaults themselves, so only clearly-toned answers count). */ +export function polarityBackgroundFromForeground(hex: string): string | undefined { + const luminance = relativeLuminance(hex) + + if (luminance === null || hex === '#000000' || hex === '#ffffff') { + return undefined + } + + if (luminance >= 0.45) { + return '#1e1e1e' + } + + if (luminance <= 0.2) { + return '#ffffff' + } + + return undefined +} + export function syncThemeToTerminalBackground(): void { if (themeBackgroundSyncStarted) { return @@ -165,8 +191,9 @@ export function syncThemeToTerminalBackground(): void { // answers OSC 11 with its own black fallback regardless of the outer // terminal — and tmux also strips TERM_PROGRAM, so no host allow-list // can catch it. Real dark themes report their actual surface (#1e1e1e, - // #282828, …). Distrusting pure black universally is safe: on a truly - // pure-black terminal the fall-through detection lands on dark anyway. + // #282828, …). Distrusting pure black universally is safe: the OSC-10 + // foreground below resolves the pole for transparent hosts, and a truly + // pure-black terminal lands on dark either way. if (hex === '#000000') { return } @@ -176,6 +203,26 @@ export function syncThemeToTerminalBackground(): void { reapplyTheme() }) + // Foreground tiebreaker for the distrusted-background case. The two OSC + // replies arrive in the same startup batch; this listener only commits when + // the background didn't (first-writer-wins via `resolved`), and an explicit + // user pin still outranks it inside detectLightMode. + onTerminalForeground(hex => { + if (resolved || process.env.HERMES_TUI_THEME || process.env.HERMES_TUI_LIGHT) { + return + } + + const inferred = polarityBackgroundFromForeground(hex) + + if (!inferred) { + return + } + + resolved = true + process.env.HERMES_TUI_BACKGROUND = inferred + reapplyTheme() + }) + // Last-resort inference when the probe never answers (or answered with the // untrusted default): on macOS, editor themes overwhelmingly track the // system appearance, so `AppleInterfaceStyle` is a strong prior. Runs only diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index ea73d338d421..0e92c307650c 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -109,6 +109,8 @@ declare module '@hermes/ink' { export function isXtermJs(): boolean export function onTerminalBackground(listener: (hex: string) => void): void export function terminalBackgroundHex(): string | undefined + export function onTerminalForeground(listener: (hex: string) => void): void + export function terminalForegroundHex(): string | undefined export function parseOscColor(data: string): string | undefined export type ScrollFastPathStats = { From e594e11baa5df9fe1f52e78907086e13dee0b6cd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:47:16 -0500 Subject: [PATCH 10/22] =?UTF-8?q?fix(ui-tui):=20session-panel=20fade=20anc?= =?UTF-8?q?hors=20on=20muted,=20not=20the=20surface=20=E2=80=94=20readable?= =?UTF-8?q?=20on=20every=20pole?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mix(text, surface) was invisible whenever text was already pale (the light-rendered default: cream blended toward white = nothing) and inherited wrong-polarity detection through the surface. mix(muted, text, .5) is pole-proof: muted is mid-luminance by construction, so the midpoint stays readable everywhere. Audited 9 skins x both poles: 2.0-2.9:1 on white, 5.6-9.3:1 on dark (worst case 1.92 for a light-authored skin on dark). --- ui-tui/src/components/branding.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 513d5a57624d..87006a5e0c97 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -233,10 +233,12 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s) // Hierarchy: category labels lead in the theme's label tone; member lists - // recede in body-text faded toward the surface. Skin-relative on purpose — - // `muted` is the STRONG family tone on gold skins but the weak one on blue - // skins, so hardcoding it flips the hierarchy per skin (slate/poseidon bug). - const listFade = mix(t.color.text, t.color.completionBg, 0.5) + // recede in the midpoint of muted and text. Anchoring on MUTED (a + // mid-luminance family tone) keeps the fade readable on both poles even + // when polarity detection is wrong — blending toward the SURFACE instead + // made the fade invisible whenever text was already pale (light-rendered + // default: cream toward white = nothing). + const listFade = mix(t.color.muted, t.color.text, 0.5) // ── Local collapse state for each section ── const [toolsOpen, setToolsOpen] = useState(true) From 505b2de484bde1ef52f47d71eeb7ec11a4b6b6d4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:53:14 -0500 Subject: [PATCH 11/22] style(skins): default light overlay = goldenrod ladder, not neon or mustard On white, the vivid #FFD700/#FFBF00 read as glare and the WCAG-darkened mustard reads as mud. The sweet spot is the statusbar's goldenrod family (hue kept, saturation tamed, mid luminance): title #C8961E, headers #D89B04, labels #A97E10, muted #B8860B unchanged, warm bronze ink body #5C4718, deepened semantics + shell blue. Hierarchy on white: ink 8.9:1 > fade 5.2 > label 3.7 > muted 3.3 > title 2.7 > headers 2.4. Dark mode renders the vivid block untouched (explicitly approved as-is). --- hermes_cli/skin_engine.py | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 3a89554f131e..b176ca297da9 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -215,17 +215,36 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "shell_dollar": "#4dabf7", "voice_status_bg": "#1a1a2e", }, - # No paired light_colors. On a light (in practice transparent) terminal - # the beloved classic look is these vivid golds rendered RAW — xterm - # applies no contrast lift over a transparent bg, so #FFD700 shows as - # bright #F5C242, not a WCAG-darkened mustard. The TUI's display shim - # only flips fills to light polarity and rescues genuinely-invisible - # near-white text; the golds pass through untouched. A hand-authored - # dark "light palette" here is exactly what made it read as mud. + # Light overlay (merged onto `colors`; dark mode renders the vivid + # block above untouched). The goldenrod ladder: on white, the vivid + # #FFD700/#FFBF00 read as glare and WCAG-darkened mustard (#867000) + # reads as mud — the sweet spot is the statusbar's goldenrod family + # (#B8860B/#DAA520): hue kept, saturation tamed, mid luminance. + # Hierarchy on white: ink body 8.9:1 > fade 5.2 > label 3.7 > + # muted 3.3 > title 2.7 > headers 2.4 (accents recede last, like + # slate's pastels — the raw-canon look, just not neon). "light_colors": { - # Fills only: on a light terminal the dark navy menu/status fills - # must flip to light. Foregrounds intentionally inherit the vivid - # `colors` above so they render raw. + "banner_title": "#C8961E", + "banner_accent": "#D89B04", + "banner_dim": "#B8860B", + "banner_text": "#5C4718", + "ui_accent": "#D89B04", + "ui_label": "#A97E10", + "ui_ok": "#2E7D32", + "ui_error": "#C62828", + "ui_warn": "#D97706", + "prompt": "#5C4718", + "response_border": "#C8961E", + "session_label": "#A97E10", + "status_bar_text": "#6F6F6F", + "status_bar_strong": "#C8961E", + "status_bar_dim": "#9A8A5A", + "status_bar_good": "#2E7D32", + "status_bar_warn": "#C8961E", + "status_bar_bad": "#C2410C", + "status_bar_critical": "#B91C1C", + "shell_dollar": "#1E6FC0", + # Fills: flip the dark navy surfaces to light polarity. "completion_menu_bg": "#F5F5F5", "completion_menu_current_bg": "#E0D1BF", "selection_bg": "#D4E4F7", From a0a9aaf8b8c949957930b28d22ff54b55ececaeb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:57:58 -0500 Subject: [PATCH 12/22] =?UTF-8?q?refactor(ui-tui):=20DRY=20the=20chrome=20?= =?UTF-8?q?primitives=20=E2=80=94=20shared=20scrollbarColors=20+=20hintRgb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrollbarColors(t, hover, grabbed) in overlayPrimitives is now THE scheme for both scrollbars (was duplicated formulas); textInput's hex parsing collapses into one hintRgb helper feeding colorizeHint and hintCursorCell. Comment bloat trimmed. No behavior change — 1243 tests byte-green. --- ui-tui/src/components/appChrome.tsx | 25 ++++---------- ui-tui/src/components/branding.tsx | 10 +++--- ui-tui/src/components/overlayPrimitives.tsx | 15 ++++++++- ui-tui/src/components/overlayScrollbar.tsx | 9 ++--- ui-tui/src/components/textInput.tsx | 37 +++++++++------------ 5 files changed, 44 insertions(+), 52 deletions(-) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 2f6eb417fc3d..556770394d38 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -11,13 +11,14 @@ import { FACES } from '../content/faces.js' import { VERBS } from '../content/verbs.js' import { fmtDuration } from '../domain/messages.js' import { stickyPromptFromViewport } from '../domain/viewport.js' -import { mix } from '../lib/color.js' import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js' import { fmtK } from '../lib/text.js' import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js' import type { Theme } from '../theme.js' import type { Msg, Usage } from '../types.js' +import { scrollbarColors } from './overlayPrimitives.js' + const FACE_TICK_MS = 2500 const HEART_COLORS = ['#ff5fa2', '#ff4d6d'] @@ -754,14 +755,7 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp const travel = Math.max(1, vp - thumb) const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0 - // Thumb rides in the theme's BASE color (primary), brightening to accent - // while hovered/dragged. The track recedes via an EXPLICIT blend toward the - // theme surface — never the SGR dim attribute: dim is terminal-interpreted, - // and on transparent profiles (terminal.background #00000000) xterm renders - // dim cells as half-bright glyphs on an opaque BLACK cell background. That - // was the "black bar / black thumb" on the right edge. - const thumbColor = grab !== null || hover ? t.color.accent : t.color.primary - const trackColor = mix(hover ? t.color.border : t.color.muted, t.color.completionBg, hover ? 0.25 : 0.55) + const { thumb: thumbColor, track: trackColor } = scrollbarColors(t, hover, grab !== null) const jump = (row: number, offset: number) => { if (!s || !scrollable) { @@ -793,15 +787,10 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) }} width={1} > - {!scrollable ? null : ( // Nothing to scroll → draw nothing. The outer - // width={1} Box still reserves the column so the transcript width - // doesn't jump when content grows scrollable. Previously this drew a - // full-height column of SPACES; on a transparent terminal - // (terminal.background #00000000) those drawn-blank cells composite to - // a black bar (the "big black area" by the scrollbar) — same class as - // the banner's removed opaque fill. A `│` track only appears when - // there's actually something to scroll, which is what reads as "makes - // sense." + {/* Nothing to scroll → draw nothing (the width={1} Box still reserves + the column). Drawn-blank cells composite to a black bar on + transparent terminals — same class as the removed opaque fills. */} + {!scrollable ? null : ( <> {thumbTop > 0 ? ( {`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`} diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 87006a5e0c97..2bafc0c6ae5a 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -232,12 +232,10 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { const lineBudget = Math.max(12, w - 2) const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s) - // Hierarchy: category labels lead in the theme's label tone; member lists - // recede in the midpoint of muted and text. Anchoring on MUTED (a - // mid-luminance family tone) keeps the fade readable on both poles even - // when polarity detection is wrong — blending toward the SURFACE instead - // made the fade invisible whenever text was already pale (light-rendered - // default: cream toward white = nothing). + // Hierarchy: labels lead in the label tone; member lists recede in the + // muted/text midpoint. Anchoring on MUTED (mid-luminance by construction) + // keeps the fade readable on both poles even when polarity detection is + // wrong — surface-relative blends go invisible when text is already pale. const listFade = mix(t.color.muted, t.color.text, 0.5) // ── Local collapse state for each section ── diff --git a/ui-tui/src/components/overlayPrimitives.tsx b/ui-tui/src/components/overlayPrimitives.tsx index a001011e5f78..1884841d5d25 100644 --- a/ui-tui/src/components/overlayPrimitives.tsx +++ b/ui-tui/src/components/overlayPrimitives.tsx @@ -3,9 +3,22 @@ import { Text, useInput } from '@hermes/ink' import { type ReactNode, useState } from 'react' import type { UsageModelData } from '../gatewayTypes.js' -import { liftForContrast } from '../lib/color.js' +import { liftForContrast, mix } from '../lib/color.js' import type { Theme } from '../theme.js' +/** + * THE scrollbar treatment (transcript + overlays): thumb rides the theme + * base, accent while interacting; track recedes via an explicit blend toward + * the surface. Never SGR dim — terminal-interpreted, it renders as a black + * slab on transparent profiles (terminal.background #00000000). + */ +export function scrollbarColors(t: Theme, hover: boolean, grabbed: boolean): { thumb: string; track: string } { + return { + thumb: grabbed || hover ? t.color.accent : t.color.primary, + track: mix(hover ? t.color.border : t.color.muted, t.color.completionBg, hover ? 0.25 : 0.55) + } +} + export interface MenuRowSpec { color?: string label: string diff --git a/ui-tui/src/components/overlayScrollbar.tsx b/ui-tui/src/components/overlayScrollbar.tsx index 56e93f3f23e5..d12b147a8954 100644 --- a/ui-tui/src/components/overlayScrollbar.tsx +++ b/ui-tui/src/components/overlayScrollbar.tsx @@ -1,9 +1,10 @@ import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' import { type RefObject, useState } from 'react' -import { mix } from '../lib/color.js' import type { Theme } from '../theme.js' +import { scrollbarColors } from './overlayPrimitives.js' + /** * Mouse-draggable scrollbar bound to a `ScrollBox` ref. Re-renders off the * parent `tick` so accordions / async content can resize the thumb without a @@ -40,11 +41,7 @@ export function OverlayScrollbar({ const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}│` : '') const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}┃` - // Same scheme as TranscriptScrollbar: base-color thumb, accent on interact, - // track receded via explicit blend (SGR dim renders as a black slab on - // transparent terminals — see TranscriptScrollbar). - const thumbColor = grab !== null || hover ? t.color.accent : t.color.primary - const trackColor = mix(hover ? t.color.border : t.color.muted, t.color.completionBg, hover ? 0.25 : 0.55) + const { thumb: thumbColor, track: trackColor } = scrollbarColors(t, hover, grab !== null) const jump = (row: number, offset: number) => { if (!s || !scrollable) { diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 4c7ea4f21d63..c049ce078ff2 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -40,33 +40,28 @@ type MinimalEnv = Record const invert = (s: string) => INV + s + INV_OFF -/** Truecolor foreground wrap. Always emits an EXPLICIT color — never SGR dim - * and never inverse: both are terminal-interpreted relative to the default - * fg/bg, and on transparent profiles (terminal.background #00000000) they - * composite against a black RGB the user never sees elsewhere, rendering the - * hint as a black slab. A fixed mid-gray fallback is deterministic on every - * host when no theme color is provided. */ +// Placeholder styling is EXPLICIT truecolor only — never SGR dim/inverse: +// both are terminal-interpreted relative to the default fg/bg, and on +// transparent profiles (terminal.background #00000000) they composite +// against a black RGB the user never sees — the hint rendered as a slab. const HINT_FALLBACK = '#808080' -const colorizeHint = (s: string, hex?: string) => { - const m = /^#([0-9a-f]{6})$/i.exec(hex ?? '') ?? /^#([0-9a-f]{6})$/i.exec(HINT_FALLBACK)! - const n = parseInt(m[1]!, 16) +const hintRgb = (hex?: string): [number, number, number] => { + const n = parseInt((/^#([0-9a-f]{6})$/i.exec(hex ?? '')?.[1] ?? HINT_FALLBACK.slice(1)) as string, 16) - return `${ESC}[38;2;${(n >> 16) & 0xff};${(n >> 8) & 0xff};${n & 0xff}m${s}${ESC}[39m` + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff] } -/** Synthetic placeholder cursor: an explicit-truecolor chip (hint-colored - * block, luminance-picked ink) instead of SGR inverse. Inverse swaps - * against the terminal's DEFAULT fg/bg, which on transparent profiles is a - * black RGB the user never sees — the "cursor" rendered as a black slab. */ +const colorizeHint = (s: string, hex?: string) => { + const [r, g, b] = hintRgb(hex) + + return `${ESC}[38;2;${r};${g};${b}m${s}${ESC}[39m` +} + +/** Synthetic placeholder cursor: a hint-colored chip with luminance-picked + * ink, standing in for the hidden hardware cursor (bubbles pattern). */ const hintCursorCell = (ch: string, hex?: string) => { - const m = /^#([0-9a-f]{6})$/i.exec(hex ?? '') ?? /^#([0-9a-f]{6})$/i.exec(HINT_FALLBACK)! - const n = parseInt(m[1]!, 16) - - const r = (n >> 16) & 0xff, - g = (n >> 8) & 0xff, - b = n & 0xff - + const [r, g, b] = hintRgb(hex) const ink = 0.2126 * r + 0.7152 * g + 0.0722 * b > 140 ? '0;0;0' : '255;255;255' return `${ESC}[48;2;${r};${g};${b}m${ESC}[38;2;${ink}m${ch}${ESC}[39m${ESC}[49m` From ce8c2c97aa4af369a5500483b533b2896a02972c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 18:01:21 -0500 Subject: [PATCH 13/22] =?UTF-8?q?fix(ui-tui):=20completions=20popover=20?= =?UTF-8?q?=E2=80=94=20aligned=20name=20track=20+=20neutral=20descriptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-column grid: the name track auto-sizes to the widest visible command so descriptions align in their own column instead of running under the names. Descriptions render in the neutral statusFg gray — label and muted are near-twins on the gold skins, which made command and description read as one unparseable run. --- ui-tui/src/components/appOverlays.tsx | 67 +++++++++++++++------------ 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index be22e2bf83a5..ff512f8f12bb 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -1,4 +1,4 @@ -import { Box, Text } from '@hermes/ink' +import { Box, stringWidth, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' import type { ReactNode } from 'react' @@ -354,37 +354,44 @@ export function FloatingOverlays({ disagree with every other overlay). Only the ACTIVE row carries a selection chip, mirroring the session switcher. */} - {completions.slice(start, start + viewportSize).map((item, i) => { - const active = start + i === compIdx - const row = listRowStyle(theme, active) + {(() => { + const visible = completions.slice(start, start + viewportSize) + // Two-column grid: the name track auto-sizes to the widest + // visible command, so descriptions align — and wrapped + // description lines stay inside their own column instead of + // running under the names. + const nameW = Math.max(...visible.map(item => stringWidth(item.display))) + 2 - return ( - - {/* flexShrink=0 — when meta overflows the row, Ink/Yoga - otherwise shaves the last char off the display column - (e.g. /goal renders as /goa). */} - - - {' '} - {item.display} - + return visible.map((item, i) => { + const active = start + i === compIdx + const row = listRowStyle(theme, active) + + return ( + + + + {' '} + {item.display} + + + {item.meta ? ( + // Descriptions in the neutral gray, NOT a gold-family + // tone — label vs muted are near-twins on some skins, + // which made command and description read as one run. + // Active row: meta rides the chip, so it uses row ink. + + {item.meta} + + ) : null} - {item.meta ? ( - // Active row: meta rides the chip, so it uses the row ink — - // muted-on-chip can drop under 1.5:1 on dark accent chips. - - {' '} - {item.meta} - - ) : null} - - ) - })} + ) + }) + })()}
) From 5bfffcd4457bf0cfb9cca84a3fc9eae5c78089ef Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 18:35:56 -0500 Subject: [PATCH 14/22] =?UTF-8?q?refactor(ui-tui):=20every=20selectable=20?= =?UTF-8?q?list=20rides=20the=20shared=20selection=20chip=20=E2=80=94=20ze?= =?UTF-8?q?ro=20inverse=20left?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /agents, /journey, model picker, skills hub, plugins hub, pet picker, and the approval/confirm prompts all used SGR inverse for the active row — terminal-interpreted against unknowable defaults (black slab on transparent profiles) and visually divergent from completions/session-switcher. New spreadable chipRowProps(t, active) in overlayPrimitives (chip bg + lifted ink + bold; spread after `color` so chip ink wins) converts each site to the one selection treatment. rg confirms zero inverse={} remaining in components/. --- ui-tui/src/components/agentsOverlay.tsx | 10 +++++++--- ui-tui/src/components/journey.tsx | 9 ++++++--- ui-tui/src/components/modelPicker.tsx | 11 +++++------ ui-tui/src/components/overlayPrimitives.tsx | 10 ++++++++++ ui-tui/src/components/petPicker.tsx | 3 ++- ui-tui/src/components/pluginsHub.tsx | 6 +++--- ui-tui/src/components/prompts.tsx | 5 +++-- ui-tui/src/components/skillsHub.tsx | 17 +++-------------- 8 files changed, 39 insertions(+), 32 deletions(-) diff --git a/ui-tui/src/components/agentsOverlay.tsx b/ui-tui/src/components/agentsOverlay.tsx index ba9d4b74bb7c..cecfa0ef6ba8 100644 --- a/ui-tui/src/components/agentsOverlay.tsx +++ b/ui-tui/src/components/agentsOverlay.tsx @@ -32,6 +32,7 @@ import { compactPreview } from '../lib/text.js' import type { Theme } from '../theme.js' import type { SubagentNode, SubagentProgress } from '../types.js' +import { listRowStyle } from './overlayPrimitives.js' import { OverlayScrollbar } from './overlayScrollbar.js' // ── Types + lookup tables ──────────────────────────────────────────── @@ -464,14 +465,17 @@ function ListRow({ const paren = line ? line.indexOf('(') : -1 const toolShort = line ? (paren > 0 ? line.slice(0, paren) : line).trim() : '' const trailing = toolShort ? ` · ${compactPreview(toolShort, 14)}` : '' - const fg = active ? t.color.accent : t.color.text + // Selection chip, not `inverse` — inverse swaps against the terminal's + // unknowable defaults (black slab on transparent profiles). + const row = listRowStyle(t, active) + const fg = active ? (row.color ?? t.color.accent) : t.color.text return ( - + {' '} {formatRowId(index)} {indentFor(node.item.depth)} - {heatMarker ? : null} + {heatMarker ? : null} {glyph} {goal} {toolsCount} diff --git a/ui-tui/src/components/journey.tsx b/ui-tui/src/components/journey.tsx index d1aa2c7daaa3..f6e1b0790206 100644 --- a/ui-tui/src/components/journey.tsx +++ b/ui-tui/src/components/journey.tsx @@ -7,6 +7,7 @@ import { rpcErrorMessage } from '../lib/rpc.js' import { deriveStarmapPalette, fadeHex, fadeInk, type StarmapPalette } from '../lib/starmapPalette.js' import type { Theme } from '../theme.js' +import { listRowStyle } from './overlayPrimitives.js' import { OverlayScrollbar } from './overlayScrollbar.js' interface MutationResult { @@ -115,12 +116,14 @@ function ChartRow({ palette, row }: { palette: StarmapPalette; row: Run[] }) { } // Full-width selectable row, matching the /agents list treatment: the active -// row inverts and collapses every segment onto the accent foreground. +// row carries the shared selection chip (never `inverse`, which swaps against +// the terminal's unknowable defaults) and collapses onto the chip ink. function ListRow({ active, cells, t }: { active: boolean; cells: Cell[]; t: Theme }) { - const fg = active ? t.color.accent : t.color.text + const row = listRowStyle(t, active) + const fg = active ? (row.color ?? t.color.accent) : t.color.text return ( - + {cells.map((c, i) => ( {c.text} diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 9983743ac9b2..75bdbe7bd39c 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -10,6 +10,7 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' import { OverlayHint, useOverlayKeys, windowItems } from './overlayControls.js' +import { chipRowProps } from './overlayPrimitives.js' const VISIBLE = 12 const MIN_WIDTH = 40 @@ -596,9 +597,8 @@ export function ModelPicker({ return row ? ( @@ -669,9 +669,8 @@ export function ModelPicker({ return ( diff --git a/ui-tui/src/components/overlayPrimitives.tsx b/ui-tui/src/components/overlayPrimitives.tsx index 1884841d5d25..805ffdbe8652 100644 --- a/ui-tui/src/components/overlayPrimitives.tsx +++ b/ui-tui/src/components/overlayPrimitives.tsx @@ -86,6 +86,16 @@ export function listRowStyle(t: Theme, active: boolean): { backgroundColor?: str return { backgroundColor, color: liftForContrast(t.color.text, backgroundColor, 4.5) } } +/** Spreadable props for a selectable row: chip bg + ink + bold when active. + * Spread AFTER `color` so the chip ink wins on the active row. Replaces + * `inverse`, which swaps against the terminal's unknowable default colors + * (a black slab on transparent profiles). */ +export function chipRowProps(t: Theme, active: boolean): { backgroundColor?: string; bold: boolean; color?: string } { + const row = listRowStyle(t, active) + + return { backgroundColor: row.backgroundColor, bold: active, ...(row.color ? { color: row.color } : {}) } +} + /** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). Active rows * carry the shared list-row selection chip — same treatment as completions * and the session switcher — instead of `inverse`, whose contrast depends on diff --git a/ui-tui/src/components/petPicker.tsx b/ui-tui/src/components/petPicker.tsx index f8ba4594362c..142139e37da4 100644 --- a/ui-tui/src/components/petPicker.tsx +++ b/ui-tui/src/components/petPicker.tsx @@ -6,6 +6,7 @@ import { rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' import { OverlayHint, windowItems } from './overlayControls.js' +import { chipRowProps } from './overlayPrimitives.js' const VISIBLE = 10 const MIN_WIDTH = 40 @@ -155,7 +156,7 @@ export function PetPicker({ gw, maxWidth, onClose, t }: PetPickerProps) { const tag = pet.installed ? '' : pet.curated ? ' · official' : '' return ( - + {at ? '▸ ' : ' '} {mark} {pet.displayName} diff --git a/ui-tui/src/components/pluginsHub.tsx b/ui-tui/src/components/pluginsHub.tsx index 3876c831299a..358878010535 100644 --- a/ui-tui/src/components/pluginsHub.tsx +++ b/ui-tui/src/components/pluginsHub.tsx @@ -6,6 +6,7 @@ import { rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js' +import { chipRowProps } from './overlayPrimitives.js' const VISIBLE = 12 const MIN_WIDTH = 44 @@ -209,9 +210,8 @@ export function PluginsHub({ gw, maxWidth, onClose, t }: PluginsHubProps) { return ( diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index bfd7bf62d278..614be012aaad 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -5,6 +5,7 @@ import { isMac } from '../lib/platform.js' import type { Theme } from '../theme.js' import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js' +import { chipRowProps } from './overlayPrimitives.js' import { TextInput } from './textInput.js' const APPROVAL_OPTS = ['once', 'session', 'always', 'deny'] as const @@ -129,7 +130,7 @@ export function ApprovalPrompt({ cols = 80, onChoice, req, t }: ApprovalPromptPr {opts.map((o, i) => ( - + {sel === i ? '▸ ' : ' '} {i + 1}. {LABELS[o]} @@ -208,7 +209,7 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify {[...choices, 'Other (type your answer)'].map((c, i) => ( - + {sel === i ? '▸ ' : ' '} {i + 1}. {c} diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx index 31c8f355f007..dec053db27e4 100644 --- a/ui-tui/src/components/skillsHub.tsx +++ b/ui-tui/src/components/skillsHub.tsx @@ -6,6 +6,7 @@ import { rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js' +import { chipRowProps } from './overlayPrimitives.js' const VISIBLE = 12 const MIN_WIDTH = 40 @@ -220,13 +221,7 @@ export function SkillsHub({ gw, maxWidth, onClose, t }: SkillsHubProps) { const idx = offset + i return ( - + {catIdx === idx ? '▸ ' : ' '} {i + 1}. {row} @@ -256,13 +251,7 @@ export function SkillsHub({ gw, maxWidth, onClose, t }: SkillsHubProps) { const idx = offset + i return ( - + {skillIdx === idx ? '▸ ' : ' '} {i + 1}. {row} From 11f2e54f0c5f27346c2115c144ae3a6104dbde7b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 13:35:37 -0500 Subject: [PATCH 15/22] =?UTF-8?q?fix(ui-tui):=20overlay=20width=20caps=20a?= =?UTF-8?q?re=20absolute=20=E2=80=94=20clampOverlayWidth=20(Copilot=20revi?= =?UTF-8?q?ew)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every picker/hub forced width >= 24 AFTER applying the caller's maxWidth, so a grid cell narrower than 24 (or a FloatBox cell under 28 with its 4 cols of chrome) overflowed and clipped at the terminal edge. One shared clampOverlayWidth(preferred, maxWidth, min=24): the caller's cap is ABSOLUTE (a cell knows its budget), the usability floor applies only when the cap allows it, uncapped keeps the old floor semantics. Five call sites (model/pet pickers, skills/plugins hubs, session switcher) route through it; the grid-test FloatBox drops its own 24 floor. Contract-tested including the sub-floor cap case from the review. --- .../src/__tests__/overlayPrimitives.test.ts | 22 +++++++++++++++++++ .../src/components/activeSessionSwitcher.tsx | 4 ++-- ui-tui/src/components/appOverlays.tsx | 5 ++++- ui-tui/src/components/modelPicker.tsx | 4 ++-- ui-tui/src/components/overlayPrimitives.tsx | 11 ++++++++++ ui-tui/src/components/petPicker.tsx | 4 ++-- ui-tui/src/components/pluginsHub.tsx | 4 ++-- ui-tui/src/components/skillsHub.tsx | 4 ++-- 8 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 ui-tui/src/__tests__/overlayPrimitives.test.ts diff --git a/ui-tui/src/__tests__/overlayPrimitives.test.ts b/ui-tui/src/__tests__/overlayPrimitives.test.ts new file mode 100644 index 000000000000..27cbd336a86d --- /dev/null +++ b/ui-tui/src/__tests__/overlayPrimitives.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { clampOverlayWidth } from '../components/overlayPrimitives.js' + +describe('clampOverlayWidth', () => { + it('prefers preferred, capped by maxWidth', () => { + expect(clampOverlayWidth(60)).toBe(60) + expect(clampOverlayWidth(60, 40)).toBe(40) + expect(clampOverlayWidth(30, 80)).toBe(30) + }) + + it('honors caps BELOW the usability floor instead of overflowing the cell', () => { + // Copilot review on #20379: a 20-col grid cell must get 20, not 24. + expect(clampOverlayWidth(60, 20)).toBe(20) + expect(clampOverlayWidth(60, 1)).toBe(1) + }) + + it('keeps the floor when the cap allows it', () => { + expect(clampOverlayWidth(10, 80)).toBe(24) + expect(clampOverlayWidth(10)).toBe(24) + }) +}) diff --git a/ui-tui/src/components/activeSessionSwitcher.tsx b/ui-tui/src/components/activeSessionSwitcher.tsx index 34e60d7d4f42..52668c1d51f3 100644 --- a/ui-tui/src/components/activeSessionSwitcher.tsx +++ b/ui-tui/src/components/activeSessionSwitcher.tsx @@ -16,7 +16,7 @@ import type { Theme } from '../theme.js' import { ModelPicker } from './modelPicker.js' import { windowOffset } from './overlayControls.js' -import { listRowStyle } from './overlayPrimitives.js' +import { clampOverlayWidth, listRowStyle } from './overlayPrimitives.js' import { TextInput } from './textInput.js' const VISIBLE = 12 @@ -325,7 +325,7 @@ export function ActiveSessionSwitcher({ const { stdout } = useStdout() // Optional maxWidth lets grid layouts hand the switcher its cell budget. const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) - const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth))) + const width = clampOverlayWidth(preferredWidth, maxWidth) const promptColumns = Math.max(20, width - 11) // Rows are [new][live…][history…]: the "+ new" row is pinned first (index 0, diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index ff512f8f12bb..a6a40117550c 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -227,7 +227,10 @@ export function FloatingOverlays({ id: 'grid-test', render: () => ( - + {/* cols-6 = FloatBox chrome (4) + margin (2); no 24-col floor — + forcing one would overflow cells narrower than 28 and clip at + the terminal edge. */} + ) }) diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 75bdbe7bd39c..e52d12718922 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -10,7 +10,7 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' import { OverlayHint, useOverlayKeys, windowItems } from './overlayControls.js' -import { chipRowProps } from './overlayPrimitives.js' +import { chipRowProps, clampOverlayWidth } from './overlayPrimitives.js' const VISIBLE = 12 const MIN_WIDTH = 40 @@ -62,7 +62,7 @@ export function ModelPicker({ // has an actual constraint to truncate against. Optional maxWidth lets // grid layouts hand the picker its cell budget. const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) - const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth))) + const width = clampOverlayWidth(preferredWidth, maxWidth) useEffect(() => { gw.request('model.options', { diff --git a/ui-tui/src/components/overlayPrimitives.tsx b/ui-tui/src/components/overlayPrimitives.tsx index 805ffdbe8652..f86787f4ae48 100644 --- a/ui-tui/src/components/overlayPrimitives.tsx +++ b/ui-tui/src/components/overlayPrimitives.tsx @@ -6,6 +6,17 @@ import type { UsageModelData } from '../gatewayTypes.js' import { liftForContrast, mix } from '../lib/color.js' import type { Theme } from '../theme.js' +/** + * Overlay width clamp: prefer `preferred`, honor the caller's `maxWidth` + * ABSOLUTELY (a grid cell knows its budget — overflowing it clips at the + * terminal edge), and keep a usability floor only when the cap allows it. + */ +export function clampOverlayWidth(preferred: number, maxWidth?: number, min = 24): number { + const cap = maxWidth === undefined ? Number.MAX_SAFE_INTEGER : Math.max(1, Math.trunc(maxWidth)) + + return Math.max(Math.min(min, cap), Math.min(preferred, cap)) +} + /** * THE scrollbar treatment (transcript + overlays): thumb rides the theme * base, accent while interacting; track recedes via an explicit blend toward diff --git a/ui-tui/src/components/petPicker.tsx b/ui-tui/src/components/petPicker.tsx index 142139e37da4..42bc91c0e02c 100644 --- a/ui-tui/src/components/petPicker.tsx +++ b/ui-tui/src/components/petPicker.tsx @@ -6,7 +6,7 @@ import { rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' import { OverlayHint, windowItems } from './overlayControls.js' -import { chipRowProps } from './overlayPrimitives.js' +import { chipRowProps, clampOverlayWidth } from './overlayPrimitives.js' const VISIBLE = 10 const MIN_WIDTH = 40 @@ -42,7 +42,7 @@ export function PetPicker({ gw, maxWidth, onClose, t }: PetPickerProps) { const { stdout } = useStdout() // Optional maxWidth lets grid layouts hand the picker its cell budget. const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) - const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth))) + const width = clampOverlayWidth(preferredWidth, maxWidth) useEffect(() => { gw.request('pet.gallery') diff --git a/ui-tui/src/components/pluginsHub.tsx b/ui-tui/src/components/pluginsHub.tsx index 358878010535..e8578886462d 100644 --- a/ui-tui/src/components/pluginsHub.tsx +++ b/ui-tui/src/components/pluginsHub.tsx @@ -6,7 +6,7 @@ import { rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js' -import { chipRowProps } from './overlayPrimitives.js' +import { chipRowProps, clampOverlayWidth } from './overlayPrimitives.js' const VISIBLE = 12 const MIN_WIDTH = 44 @@ -53,7 +53,7 @@ export function PluginsHub({ gw, maxWidth, onClose, t }: PluginsHubProps) { const { stdout } = useStdout() // Optional maxWidth lets grid layouts hand the hub its cell budget. const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) - const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth))) + const width = clampOverlayWidth(preferredWidth, maxWidth) const load = () => { gw.request('plugins.manage', { action: 'list' }) diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx index dec053db27e4..87ec3339d3bb 100644 --- a/ui-tui/src/components/skillsHub.tsx +++ b/ui-tui/src/components/skillsHub.tsx @@ -7,6 +7,7 @@ import type { Theme } from '../theme.js' import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js' import { chipRowProps } from './overlayPrimitives.js' +import { clampOverlayWidth } from './overlayPrimitives.js' const VISIBLE = 12 const MIN_WIDTH = 40 @@ -26,8 +27,7 @@ export function SkillsHub({ gw, maxWidth, onClose, t }: SkillsHubProps) { const { stdout } = useStdout() const terminalWidth = Math.max(1, (stdout?.columns ?? 80) - 6) const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, terminalWidth)) - const widthCap = Math.max(24, Math.trunc(maxWidth ?? preferredWidth)) - const width = Math.max(24, Math.min(preferredWidth, widthCap)) + const width = clampOverlayWidth(preferredWidth, maxWidth) useEffect(() => { gw.request<{ skills?: Record }>('skills.manage', { action: 'list' }) From fdac23b6412f236e8c07a4b2f3f130550fa2d336 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 13:49:54 -0500 Subject: [PATCH 16/22] fix(tui): reload.mcp lock scope + coalesced refresh + macOS polarity flip (Bugbot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real findings from the review of the reload.mcp pooling + OSC-10 work: - Lock released too early: the leader now holds _mcp_reload_lock across shutdown+discover AND its own agent refresh — releasing after discover let a second reload tear the registry down while the first was still reading it to rebuild the session's tool snapshot. - Coalesced reload skipped the agent refresh: a follower returned "reloaded" without rebuilding ITS OWN session's snapshot, so a coalesced session kept stale tools. Followers now wait, then refresh their agent against the freshly-built registry (under the lock, skipping the redundant shutdown/discover). Shared _finish_reload tail for the `always` opt-out. - macOS AppleInterfaceStyle fallback never set `resolved`, so a late OSC-10 foreground reply could re-flip the committed inference (visible churn). It now marks resolved; a real OSC-11 background measurement still corrects it (that listener intentionally doesn't gate on resolved — measurement beats inference). Bugbot's other four findings were diff-truncation false positives (color.ts, themeBoot.ts, and the mcp_rev/light_colors/dark_colors types all exist; typecheck + build green). 384 tui_gateway + TS suites pass. --- tui_gateway/server.py | 78 +++++++++++++-------- ui-tui/src/app/createGatewayEventHandler.ts | 6 ++ 2 files changed, 53 insertions(+), 31 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 18ced36f1833..cd764108330f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -12881,6 +12881,24 @@ def _(rid, params: dict) -> dict: _mcp_reload_lock = threading.Lock() +def _finish_reload(rid, params: dict, *, coalesced: bool) -> dict: + """Shared tail for both reload paths: honor ``always`` (persist the + confirm opt-out) and return the ok payload.""" + if bool(params.get("always", False)): + try: + from cli import save_config_value as _save_cfg + + _save_cfg("approvals.mcp_reload_confirm", False) + except Exception as _exc: + logger.warning("Failed to persist mcp_reload_confirm=false: %s", _exc) + + payload = {"status": "reloaded"} + if coalesced: + payload["coalesced"] = True + + return _ok(rid, payload) + + @method("reload.mcp") def _(rid, params: dict) -> dict: session = _sessions.get(params.get("session_id", "")) @@ -12935,26 +12953,16 @@ def _(rid, params: dict) -> dict: from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools - if not _mcp_reload_lock.acquire(blocking=False): - # A reload is already in flight; wait for it to finish and reuse - # its result instead of tearing the freshly-built registry down. - with _mcp_reload_lock: - pass - return _ok(rid, {"status": "reloaded", "coalesced": True}) - - try: - shutdown_mcp_servers() - discover_mcp_tools() - finally: - _mcp_reload_lock.release() - if session: + def _refresh_session_agent() -> None: + """Rebuild THIS session's cached tool snapshot from the live + registry and push session.info. The agent snapshots tools once at + build and never re-reads the registry, so an explicit rebuild is + required (mirrors gateway/run.py::_execute_mcp_reload). Runs under + _mcp_reload_lock so the registry it reads can't be torn down by a + concurrent reload mid-refresh.""" + if not session: + return agent = session["agent"] - # Rebuild the cached agent's tool snapshot so the current session - # picks up added/removed MCP tools without `/new` (which discards - # history). The agent snapshots tools once at build and never - # re-reads the registry, so an explicit rebuild is required here. - # The user already consented to the prompt-cache invalidation via - # the confirm gate above. Mirrors gateway/run.py::_execute_mcp_reload. try: from tools.mcp_tool import refresh_agent_mcp_tools @@ -12970,22 +12978,30 @@ def _(rid, params: dict) -> dict: "Failed to refresh cached agent tools after /reload-mcp: %s", _exc, ) - _emit( - "session.info", - params.get("session_id", ""), - _session_info(agent, session), - ) + _emit("session.info", params.get("session_id", ""), _session_info(agent, session)) - # Honor `always=true` by persisting the opt-out to config. - if bool(params.get("always", False)): + # Serialize reloads. The LEADER (won the non-blocking acquire) holds + # the lock across shutdown+discover AND its own agent refresh — + # releasing after discover would let a second reload tear the registry + # down while this one is still reading it to rebuild the snapshot. A + # FOLLOWER (lock busy) waits, then — still holding the lock — refreshes + # ITS OWN session against the freshly-built registry (skipping the + # redundant shutdown/discover), so a coalesced session still gets an + # updated tool snapshot rather than silently keeping stale tools. + if _mcp_reload_lock.acquire(blocking=False): try: - from cli import save_config_value as _save_cfg + shutdown_mcp_servers() + discover_mcp_tools() + _refresh_session_agent() + finally: + _mcp_reload_lock.release() - _save_cfg("approvals.mcp_reload_confirm", False) - except Exception as _exc: - logger.warning("Failed to persist mcp_reload_confirm=false: %s", _exc) + return _finish_reload(rid, params, coalesced=False) - return _ok(rid, {"status": "reloaded"}) + with _mcp_reload_lock: + _refresh_session_agent() + + return _finish_reload(rid, params, coalesced=True) except Exception as e: return _err(rid, 5015, str(e)) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 3ba6558b5d70..979d92bb535e 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -250,6 +250,12 @@ export function syncThemeToTerminalBackground(): void { // every later signal (config pin, real OSC answer) still outranks it. const dark = !error && stdout.trim() === 'Dark' + // Mark resolved so a LATE OSC-10 foreground reply (also an inference) + // can't re-flip this committed guess after the fact — visible churn. + // A real OSC-11 background answer still corrects it: that listener + // intentionally doesn't gate on `resolved` (a measurement outranks an + // inference). + resolved = true process.env.HERMES_TUI_BACKGROUND = dark ? '#1e1e1e' : '#ffffff' reapplyTheme() }) From 6982c61b8cd250b0dbc2053e7ffcf3703345d3c0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 14:11:47 -0500 Subject: [PATCH 17/22] fix(tui): mcp_rev includes mcp_servers; reload survives leader failure; /theme persists first (Bugbot r2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mcp_rev hash now covers `mcp_servers` (the server DEFINITIONS the classic CLI watches) alongside `mcp`/`tools` — editing a server previously bumped mtime but not mcp_rev, so the TUI skipped reload.mcp and new servers never connected until a manual /reload-mcp. - reload.mcp coalescing survives a failed leader: a completed-generation counter (bumped only after a successful shutdown+discover) gates the follower. If the leader threw (flapping server) the follower re-runs the full reload itself instead of returning a bogus success over an empty registry. - /theme applies AFTER config.set confirms (mirrors /indicator) with a guardedErr catch — a failed persist no longer leaves the session showing a theme that reverts on restart. 384 tui_gateway + 1246 TS tests, lint, build green. --- tui_gateway/server.py | 60 ++++++++++++++++++------ ui-tui/src/app/slash/commands/session.ts | 18 +++++-- 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index cd764108330f..facc6f878253 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -12691,8 +12691,14 @@ def _(rid, params: dict) -> dict: # but must not cost a multi-second MCP reconnect. try: cfg = _load_cfg() + # mcp_servers holds the server DEFINITIONS the classic CLI watches + # for auto-reload (cli.py::_check_config_mcp_changes) — omitting it + # meant editing a server bumped mtime but not mcp_rev, so the TUI + # skipped reload.mcp and new servers never connected until a manual + # /reload-mcp. `mcp` (settings) and `tools` (enable/disable) round + # out the MCP-relevant surface. rev_src = json.dumps( - {"mcp": cfg.get("mcp"), "tools": cfg.get("tools")}, + {"mcp": cfg.get("mcp"), "mcp_servers": cfg.get("mcp_servers"), "tools": cfg.get("tools")}, sort_keys=True, default=str, ) @@ -12879,6 +12885,11 @@ def _(rid, params: dict) -> dict: # and leave the registry half-built. Piggyback rather than queue — a reload # that arrives while one is running would just redo identical work. _mcp_reload_lock = threading.Lock() +# Bumped once per SUCCESSFUL shutdown+discover. A follower that waited on the +# lock only skips the redundant reload if this advanced while it waited — i.e. +# the leader actually completed. If the leader threw (flapping server), the +# follower sees no advance and re-runs the full reload itself. +_mcp_reload_gen = 0 def _finish_reload(rid, params: dict, *, coalesced: bool) -> dict: @@ -12980,28 +12991,47 @@ def _(rid, params: dict) -> dict: ) _emit("session.info", params.get("session_id", ""), _session_info(agent, session)) - # Serialize reloads. The LEADER (won the non-blocking acquire) holds - # the lock across shutdown+discover AND its own agent refresh — - # releasing after discover would let a second reload tear the registry - # down while this one is still reading it to rebuild the snapshot. A - # FOLLOWER (lock busy) waits, then — still holding the lock — refreshes - # ITS OWN session against the freshly-built registry (skipping the - # redundant shutdown/discover), so a coalesced session still gets an - # updated tool snapshot rather than silently keeping stale tools. + global _mcp_reload_gen + + def _do_full_reload() -> None: + """shutdown+discover+refresh under the lock, then mark a completed + generation. The lock spans the refresh too: releasing after + discover would let a second reload tear the registry down while + this one is still reading it to rebuild the session snapshot.""" + global _mcp_reload_gen + + shutdown_mcp_servers() + discover_mcp_tools() + _refresh_session_agent() + _mcp_reload_gen += 1 + + # Serialize reloads. The LEADER (won the non-blocking acquire) runs the + # full reload. A FOLLOWER (lock busy) snapshots the generation, waits, + # then — still holding the lock — checks whether a reload actually + # COMPLETED while it waited: if so it just refreshes its own agent + # against the fresh registry (coalesced); if the leader threw (flapping + # server, no generation advance) it re-runs the full reload itself, so + # a failed leader can never leave a follower reporting a bogus success + # over an empty/partial registry. if _mcp_reload_lock.acquire(blocking=False): try: - shutdown_mcp_servers() - discover_mcp_tools() - _refresh_session_agent() + _do_full_reload() finally: _mcp_reload_lock.release() return _finish_reload(rid, params, coalesced=False) - with _mcp_reload_lock: - _refresh_session_agent() + gen_before = _mcp_reload_gen - return _finish_reload(rid, params, coalesced=True) + with _mcp_reload_lock: + if _mcp_reload_gen > gen_before: + _refresh_session_agent() + coalesced = True + else: + _do_full_reload() + coalesced = False + + return _finish_reload(rid, params, coalesced=coalesced) except Exception as e: return _err(rid, 5015, str(e)) diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index df3c7b7ffa2e..e2a841c6fc20 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -432,11 +432,23 @@ export const sessionCommands: SlashCommand[] = [ return ctx.transcript.sys('usage: /theme [auto|light|dark]') } - // Apply immediately, persist in the background. - applyConfiguredTuiTheme(value) + // Apply only after the write is confirmed (mirrors /indicator): a + // failed config.set must not leave the session showing a theme that + // reverts on restart. A few ms later than an optimistic flip, but the + // env/theme state and config.yaml never disagree. ctx.gateway .rpc('config.set', { key: 'theme', value }) - .then(ctx.guarded(r => r.value !== undefined && ctx.transcript.sys(`theme → ${value}`))) + .then( + ctx.guarded(r => { + if (r.value === undefined) { + return + } + + applyConfiguredTuiTheme(value) + ctx.transcript.sys(`theme → ${value}`) + }) + ) + .catch(ctx.guardedErr) } }, From 4bb9e6cfb013241ead243e0fa80ba8424e155d33 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 14:16:44 -0500 Subject: [PATCH 18/22] fix(ui-tui): first-swap repaint + config-auto respects shell theme pin (Bugbot r3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commitTheme compares the first commit against the SEED theme uiStore mounted with (boot cache/default), not null — a cold start that resolves to a skin differing from the default previously skipped the anti-tearing forceRedraw on that first swap. - applyConfiguredTuiTheme('auto') now clears only a pin CONFIG set (tracked via configPinnedTheme), never a HERMES_TUI_THEME the user exported in their shell — that env override outranks auto-detection per detectLightMode's documented priority, and a config hydrate was wiping it. (Bugbot's recurring "GatewaySkin missing paired palette fields" is a diff-truncation false positive — gatewayTypes.ts declares light_colors/ dark_colors, typecheck green.) 81 handler + build/lint green. --- ui-tui/src/app/createGatewayEventHandler.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 979d92bb535e..ab35bc8e5851 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -68,7 +68,12 @@ const themeForSkin = (s: GatewaySkin) => { let lastCommittedTheme: Theme | null = null const commitTheme = (theme: Theme) => { - const changed = lastCommittedTheme !== null && !themesEqual(lastCommittedTheme, theme) + // First commit compares against the SEED uiStore mounted with (boot cache + // or default), not null — otherwise a first resolve that differs from the + // boot-cached theme skips the anti-tearing repaint (the exact seed≠skin + // case: cold start defaults dark, then resolves to a light skin). + const prev = lastCommittedTheme ?? getUiState().theme + const changed = !themesEqual(prev, theme) lastCommittedTheme = theme patchUiState({ theme }) @@ -113,6 +118,10 @@ export function reapplyTheme(): void { * regardless of the painted background when the editor theme leaves the * terminal background unset. */ +// True once CONFIG (via light/dark) owns the HERMES_TUI_THEME env pin, so an +// 'auto' hydrate knows not to clobber a user's shell-exported pin. +let configPinnedTheme = false + export function applyConfiguredTuiTheme(raw: unknown): void { const mode = String(raw ?? '') .trim() @@ -125,12 +134,17 @@ export function applyConfiguredTuiTheme(raw: unknown): void { return } + configPinnedTheme = true process.env.HERMES_TUI_THEME = mode } else { - if (!current) { + // 'auto' clears only a pin CONFIG set — never a HERMES_TUI_THEME the user + // exported in their shell, which is an explicit override that outranks + // auto-detection (see detectLightMode's priority order). + if (!current || !configPinnedTheme) { return } + configPinnedTheme = false delete process.env.HERMES_TUI_THEME } From 2fb0dc6e293eb156cabf764098239e95583ee336 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 14:37:44 -0500 Subject: [PATCH 19/22] fix: restore package-lock.json @esbuild platform entries (Bugbot r4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch had stripped every node_modules/@esbuild/* optional-platform entry from the lockfile (442 deletions, 0 additions) — collateral from an earlier local @esbuild/darwin-arm64 reinstall that rewrote the lockfile to this machine's platform only. esbuild still declares them as optionalDependencies, so `npm ci` on Linux CI would skip the platform binary and break Vitest / ui-tui builds. No dependency was added on this branch (the only package.json delta is a dev `visual` script), so the lockfile is restored to match main exactly. --- package-lock.json | 442 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 442 insertions(+) diff --git a/package-lock.json b/package-lock.json index 177ffa33fe57..3236f2e7310a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1927,6 +1927,448 @@ "dev": true, "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", From 9be9a91d133a0dd62facf981d5dadc9627212325 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 14:41:48 -0500 Subject: [PATCH 20/22] fix(ui-tui): seed mcp_rev baseline at startup so first cosmetic write doesn't reload MCP (Bugbot r5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup config.get seeded mtimeRef but not mcpRevRef; after a normal boot mtime is already non-zero so the poller's baseline branch never runs, leaving mcpRevRef empty. The first /skin (or other cosmetic) write bumped mtime with an unchanged mcp_rev, and empty !== hash tripped a full reload.mcp — exactly the reconnect this optimization removes. Seed the revision alongside mtime at boot. --- ui-tui/src/app/useConfigSync.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index af7eb8ab5ec0..f5f0a525f793 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -259,6 +259,11 @@ export function useConfigSync({ setVoiceEnabled(process.env.HERMES_VOICE === '1') quietRpc(gw, 'config.get', { key: 'mtime' }).then(r => { mtimeRef.current = Number(r?.mtime ?? 0) + // Seed the MCP revision baseline too: after a normal boot mtime is + // already non-zero, so the poller's baseline branch never runs, and an + // unset mcpRevRef would make the FIRST cosmetic write (mtime bump, same + // mcp_rev) look like an MCP change and fire a needless reload.mcp. + mcpRevRef.current = String(r?.mcp_rev ?? '') }) void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey) }, [gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid]) From c543c691fcaf7bc7615219dc08ba5669845701ca Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 14:46:48 -0500 Subject: [PATCH 21/22] fix(ui-tui): record config theme pin even when env already matches (Bugbot r6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from the r3 shell-pin fix: applyConfiguredTuiTheme('light'|'dark') returned early when HERMES_TUI_THEME already matched, leaving configPinnedTheme false — so a later '/theme auto' refused to clear the pin and the session stayed forced to the old mode while config said auto. Set configPinnedTheme before the match short-circuit. Bugbot's other three r6 findings (GatewaySkin paired-palette fields, ConfigMtimeResponse.mcp_rev, picker maxWidth props) are diff-truncation false positives — all declared in gatewayTypes.ts / the picker prop interfaces; typecheck is green. --- ui-tui/src/app/createGatewayEventHandler.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index ab35bc8e5851..1be2ea41598e 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -130,11 +130,15 @@ export function applyConfiguredTuiTheme(raw: unknown): void { const current = process.env.HERMES_TUI_THEME ?? '' if (mode === 'light' || mode === 'dark') { + // Record config ownership BEFORE the match short-circuit — otherwise a + // pin that already matches (e.g. env and config agree at boot) leaves + // configPinnedTheme false, and a later 'auto' would refuse to clear it. + configPinnedTheme = true + if (current === mode) { return } - configPinnedTheme = true process.env.HERMES_TUI_THEME = mode } else { // 'auto' clears only a pin CONFIG set — never a HERMES_TUI_THEME the user From 0ada7837e454d70f04ac2d8fd5ee779ab5f3da65 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 15:55:13 -0500 Subject: [PATCH 22/22] feat(ui-tui): shimmer skeleton for the lazy tools section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lazy-loaded Available Tools section rendered a BLANK gap that popped when data landed. It now shows animated shimmer rows shaped like the real content (label block + value run, diagonal band sweep), and the summary line prints "… tools · … skills" instead of "0 tools · 0 skills" while counts load. components/loaders.tsx ships the primitives (shimmerSegments band math, Shimmer, useShimmerPhase, ShimmerRows) — colors are caller-owned theme tones, one interval per composition. Cherry-picked from the SDK-shape branch so the skeleton lands with this PR; the widget-SDK consumers stay in the follow-up. --- ui-tui/src/__tests__/loaders.test.ts | 48 ++++++++++++ ui-tui/src/components/branding.tsx | 24 +++++- ui-tui/src/components/loaders.tsx | 112 +++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 ui-tui/src/__tests__/loaders.test.ts create mode 100644 ui-tui/src/components/loaders.tsx diff --git a/ui-tui/src/__tests__/loaders.test.ts b/ui-tui/src/__tests__/loaders.test.ts new file mode 100644 index 000000000000..cc3d838a2602 --- /dev/null +++ b/ui-tui/src/__tests__/loaders.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { renderToScreen } from '../../packages/hermes-ink/src/ink/render-to-screen.js' +import { cellAtIndex } from '../../packages/hermes-ink/src/ink/screen.js' +import { ShimmerRows, shimmerSegments } from '../components/loaders.js' + +describe('ShimmerRows leniency (agent-authored calls)', () => { + it('accepts a bare row COUNT and derives widths — the generated-code shape', async () => { + const { createElement } = await import('react') + + const { screen, height } = renderToScreen( + createElement(ShimmerRows, { rows: 3, width: 20, t: { color: { completionBg: '#1a1a2e', label: '#DAA520', muted: '#B8860B' } } }), + 30 + ) + + expect(height).toBe(3) + // Row 0 renders block cells, not a crash. + expect(cellAtIndex(screen, 0).char).toBe('▁') + }) +}) + +describe('shimmerSegments', () => { + it('always partitions the full width', () => { + for (let phase = -40; phase < 80; phase++) { + const [pre, band, post] = shimmerSegments(20, phase) + + expect(pre + band + post).toBe(20) + expect(Math.min(pre, band, post)).toBeGreaterThanOrEqual(0) + } + }) + + it('sweeps: enters from the left edge, exits off the right, then wraps', () => { + const bandAt = (phase: number) => shimmerSegments(10, phase, 4) + + expect(bandAt(0)).toEqual([10, 0, 0]) // band fully off-left + expect(bandAt(1)).toEqual([0, 1, 9]) // entering + expect(bandAt(7)).toEqual([3, 4, 3]) // mid-sweep + expect(bandAt(13)).toEqual([9, 1, 0]) // exiting + expect(bandAt(14)).toEqual([10, 0, 0]) // gone → next cycle re-enters + expect(bandAt(15)).toEqual([0, 1, 9]) + }) + + it('negative phases (row stagger) wrap instead of vanishing', () => { + const [pre, band, post] = shimmerSegments(10, -3, 4) + + expect(pre + band + post).toBe(10) + }) +}) diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 2bafc0c6ae5a..e8495f717e4e 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -8,6 +8,7 @@ import { flat } from '../lib/text.js' import type { Theme } from '../theme.js' import type { PanelSection, SessionInfo } from '../types.js' +import { ShimmerRows } from './loaders.js' import { WidgetGrid } from './widgetGrid.js' const LOADER_TICK_MS = 120 @@ -188,6 +189,20 @@ export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) { ) } +// ── Skeleton ───────────────────────────────────────────────────────── +// +// Lazy sections render shimmer rows shaped like the real content (label +// block + value run) instead of a blank gap that pops when data lands. +// Row widths mirror the typical toolsets listing. +const SKELETON_ROWS: readonly (readonly [number, number])[] = [ + [7, 30], + [7, 9], + [14, 12], + [12, 12], + [7, 7], + [10, 13] +] + // ── Collapsible helpers ────────────────────────────────────────────── function CollapseToggle({ @@ -299,6 +314,10 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { const mcpConnected = mcpServers.filter(s => s.connected).length const toolsBody = () => { + if (info.lazy && toolEntries.length === 0) { + return + } + const shown = toolEntries.slice(0, TOOLSETS_MAX) const overflow = toolEntries.length - TOOLSETS_MAX @@ -463,8 +482,9 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { - {toolsTotal} tools{' · '} - {skillsTotal} skills + {/* Lazy boot: never print "0 tools · 0 skills" while counts load. */} + {info.lazy && !toolsTotal ? '… ' : `${toolsTotal} `}tools{' · '} + {info.lazy && !skillsTotal ? '… ' : `${skillsTotal} `}skills {mcpConnected ? ` · ${mcpConnected} MCP` : ''} {' · '} /help for commands diff --git a/ui-tui/src/components/loaders.tsx b/ui-tui/src/components/loaders.tsx new file mode 100644 index 000000000000..ebe0bb8ce822 --- /dev/null +++ b/ui-tui/src/components/loaders.tsx @@ -0,0 +1,112 @@ +import { Box, Text } from '@hermes/ink' +import { useEffect, useState } from 'react' + +import { mix } from '../lib/color.js' + +/** + * Animated ASCII loaders — THE loading-state primitives (session panel + * skeleton, widget apps via the SDK). A highlight band sweeps across block + * runs; rows offset their phase for a diagonal shimmer. One interval per + * composition (the parent ticks, rows are pure), colors are caller-owned + * theme tones — never hardcoded. + */ + +const BAND = 7 + +/** Pure band math: [pre, band, post] cell widths for a sweep at `phase`. + * The band enters from off-left and exits off-right, wrapping. */ +export function shimmerSegments(width: number, phase: number, band = BAND): [number, number, number] { + const cycle = width + band + const start = (((phase % cycle) + cycle) % cycle) - band + const from = Math.max(0, start) + const to = Math.min(width, start + band) + + return to <= from ? [width, 0, 0] : [from, to - from, width - to] +} + +/** One shimmering run. Controlled: the parent owns `phase` so sibling rows + * stay in lockstep (offset it per row for the diagonal). */ +export function Shimmer({ + char = '▁', + color, + highlight, + phase, + width +}: { + char?: string + color: string + highlight: string + phase: number + width: number +}) { + const [pre, band, post] = shimmerSegments(width, phase) + + return ( + + {pre > 0 && {char.repeat(pre)}} + {band > 0 && {char.repeat(band)}} + {post > 0 && {char.repeat(post)}} + + ) +} + +/** Self-ticking phase for shimmer compositions. */ +export function useShimmerPhase(tickMs = 90): number { + const [phase, setPhase] = useState(0) + + useEffect(() => { + const id = setInterval(() => setPhase(p => p + 1), tickMs) + + id.unref?.() + + return () => clearInterval(id) + }, [tickMs]) + + return phase +} + +/** Skeleton rows shaped like `label: value` content, diagonal shimmer. + * + * Ergonomic for generated code (the primary author is an agent): + * - `rows` — explicit `[labelWidth, valueWidth][]` mirroring real layout, + * OR just a count (row widths derive from `width`, staggered). + * - colors — explicit `color`/`highlight`, OR pass a theme `t` and they + * derive (muted-toward-surface base, label highlight). */ +export function ShimmerRows({ + color, + highlight, + rows, + t, + width = 24 +}: { + color?: string + highlight?: string + rows: number | readonly (readonly [number, number])[] + t?: { color: { completionBg: string; label: string; muted: string } } + width?: number +}) { + const phase = useShimmerPhase() + const base = color ?? (t ? mix(t.color.muted, t.color.completionBg, 0.5) : '#808080') + const glow = highlight ?? t?.color.label ?? '#a0a0a0' + + const spec: readonly (readonly [number, number])[] = + typeof rows === 'number' + ? Array.from({ length: Math.max(1, rows) }, (_, i) => { + const label = Math.max(4, Math.round(width * 0.3) - (i % 3)) + + return [label, Math.max(4, width - label - 1)] as const + }) + : rows + + return ( + + {spec.map(([labelWidth, valueWidth], i) => ( + + + + + + ))} + + ) +}