mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(tui): harden terminal dimming and multiplexer copy (#14906)
- disable ANSI dim on VTE terminals by default so dark-background reasoning and accents stay readable - suppress local multiplexer OSC52 echo while preserving remote passthrough and add regression coverage
This commit is contained in:
parent
51f4c9827f
commit
acdcb167fb
4 changed files with 85 additions and 6 deletions
18
ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts
Normal file
18
ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { shouldUseAnsiDim } from './Text.js'
|
||||||
|
|
||||||
|
describe('shouldUseAnsiDim', () => {
|
||||||
|
it('disables ANSI dim on VTE terminals by default', () => {
|
||||||
|
expect(shouldUseAnsiDim({ VTE_VERSION: '7603' } as NodeJS.ProcessEnv)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps ANSI dim enabled elsewhere by default', () => {
|
||||||
|
expect(shouldUseAnsiDim({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honors explicit env override', () => {
|
||||||
|
expect(shouldUseAnsiDim({ HERMES_TUI_DIM: '1', VTE_VERSION: '7603' } as NodeJS.ProcessEnv)).toBe(true)
|
||||||
|
expect(shouldUseAnsiDim({ HERMES_TUI_DIM: '0' } as NodeJS.ProcessEnv)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -3,6 +3,9 @@ import React from 'react'
|
||||||
import { c as _c } from 'react/compiler-runtime'
|
import { c as _c } from 'react/compiler-runtime'
|
||||||
|
|
||||||
import type { Color, Styles } from '../styles.js'
|
import type { Color, Styles } from '../styles.js'
|
||||||
|
|
||||||
|
const ENV_ON_RE = /^(?:1|true|yes|on)$/i
|
||||||
|
const ENV_OFF_RE = /^(?:0|false|no|off)$/i
|
||||||
type BaseProps = {
|
type BaseProps = {
|
||||||
/**
|
/**
|
||||||
* Change text color. Accepts a raw color value (rgb, hex, ansi).
|
* Change text color. Accepts a raw color value (rgb, hex, ansi).
|
||||||
|
|
@ -62,6 +65,20 @@ type WeightProps =
|
||||||
}
|
}
|
||||||
export type Props = BaseProps & WeightProps
|
export type Props = BaseProps & WeightProps
|
||||||
|
|
||||||
|
export function shouldUseAnsiDim(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||||
|
const override = (env.HERMES_TUI_DIM ?? '').trim()
|
||||||
|
|
||||||
|
if (ENV_ON_RE.test(override)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ENV_OFF_RE.test(override)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !env.VTE_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
|
const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
|
||||||
wrap: {
|
wrap: {
|
||||||
flexGrow: 0,
|
flexGrow: 0,
|
||||||
|
|
@ -143,6 +160,7 @@ export default function Text(t0: Props) {
|
||||||
const strikethrough = t3 === undefined ? false : t3
|
const strikethrough = t3 === undefined ? false : t3
|
||||||
const inverse = t4 === undefined ? false : t4
|
const inverse = t4 === undefined ? false : t4
|
||||||
const wrap = t5 === undefined ? 'wrap' : t5
|
const wrap = t5 === undefined ? 'wrap' : t5
|
||||||
|
const effectiveDim = dim && shouldUseAnsiDim()
|
||||||
|
|
||||||
if (children === undefined || children === null) {
|
if (children === undefined || children === null) {
|
||||||
return null
|
return null
|
||||||
|
|
@ -174,11 +192,11 @@ export default function Text(t0: Props) {
|
||||||
|
|
||||||
let t8
|
let t8
|
||||||
|
|
||||||
if ($[4] !== dim) {
|
if ($[4] !== effectiveDim) {
|
||||||
t8 = dim && {
|
t8 = effectiveDim && {
|
||||||
dim
|
dim: effectiveDim
|
||||||
}
|
}
|
||||||
$[4] = dim
|
$[4] = effectiveDim
|
||||||
$[5] = t8
|
$[5] = t8
|
||||||
} else {
|
} else {
|
||||||
t8 = $[5]
|
t8 = $[5]
|
||||||
|
|
|
||||||
26
ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts
Normal file
26
ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { shouldEmitClipboardSequence } from './osc.js'
|
||||||
|
|
||||||
|
describe('shouldEmitClipboardSequence', () => {
|
||||||
|
it('suppresses local multiplexer clipboard OSC by default', () => {
|
||||||
|
expect(shouldEmitClipboardSequence({ TMUX: '/tmp/tmux-1/default,1,0' } as NodeJS.ProcessEnv)).toBe(false)
|
||||||
|
expect(shouldEmitClipboardSequence({ STY: '1234.pts-0.host' } as NodeJS.ProcessEnv)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps OSC enabled for remote or plain local terminals', () => {
|
||||||
|
expect(shouldEmitClipboardSequence({ SSH_CONNECTION: '1', TMUX: '/tmp/tmux-1/default,1,0' } as NodeJS.ProcessEnv)).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(shouldEmitClipboardSequence({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honors explicit env override', () => {
|
||||||
|
expect(shouldEmitClipboardSequence({ HERMES_TUI_CLIPBOARD_OSC52: '1', TMUX: '/tmp/tmux-1/default,1,0' } as NodeJS.ProcessEnv)).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(shouldEmitClipboardSequence({ HERMES_TUI_COPY_OSC52: '0', TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -11,6 +11,8 @@ import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js'
|
||||||
import type { Action, Color, TabStatusAction } from './types.js'
|
import type { Action, Color, TabStatusAction } from './types.js'
|
||||||
|
|
||||||
export const OSC_PREFIX = ESC + String.fromCharCode(ESC_TYPE.OSC)
|
export const OSC_PREFIX = ESC + String.fromCharCode(ESC_TYPE.OSC)
|
||||||
|
const ENV_ON_RE = /^(?:1|true|yes|on)$/i
|
||||||
|
const ENV_OFF_RE = /^(?:0|false|no|off)$/i
|
||||||
|
|
||||||
/** String Terminator (ESC \) - alternative to BEL for terminating OSC */
|
/** String Terminator (ESC \) - alternative to BEL for terminating OSC */
|
||||||
export const ST = ESC + '\\'
|
export const ST = ESC + '\\'
|
||||||
|
|
@ -81,6 +83,20 @@ export function getClipboardPath(): ClipboardPath {
|
||||||
return 'osc52'
|
return 'osc52'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldEmitClipboardSequence(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||||
|
const override = (env.HERMES_TUI_CLIPBOARD_OSC52 ?? env.HERMES_TUI_COPY_OSC52 ?? '').trim()
|
||||||
|
|
||||||
|
if (ENV_ON_RE.test(override)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ENV_OFF_RE.test(override)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!env['SSH_CONNECTION'] || (!env['TMUX'] && !env['STY'])
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap a payload in tmux's DCS passthrough: ESC P tmux ; <payload> ESC \
|
* Wrap a payload in tmux's DCS passthrough: ESC P tmux ; <payload> ESC \
|
||||||
* tmux forwards the payload to the outer terminal, bypassing its own parser.
|
* tmux forwards the payload to the outer terminal, bypassing its own parser.
|
||||||
|
|
@ -152,6 +168,7 @@ export async function tmuxLoadBuffer(text: string): Promise<boolean> {
|
||||||
export async function setClipboard(text: string): Promise<string> {
|
export async function setClipboard(text: string): Promise<string> {
|
||||||
const b64 = Buffer.from(text, 'utf8').toString('base64')
|
const b64 = Buffer.from(text, 'utf8').toString('base64')
|
||||||
const raw = osc(OSC.CLIPBOARD, 'c', b64)
|
const raw = osc(OSC.CLIPBOARD, 'c', b64)
|
||||||
|
const emitSequence = shouldEmitClipboardSequence(process.env)
|
||||||
|
|
||||||
// Native safety net — fire FIRST, before the tmux await, so a quick
|
// Native safety net — fire FIRST, before the tmux await, so a quick
|
||||||
// focus-switch after selecting doesn't race pbcopy. Previously this ran
|
// focus-switch after selecting doesn't race pbcopy. Previously this ran
|
||||||
|
|
@ -170,10 +187,10 @@ export async function setClipboard(text: string): Promise<string> {
|
||||||
// Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling
|
// Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling
|
||||||
// too, and BEL works everywhere for OSC 52.
|
// too, and BEL works everywhere for OSC 52.
|
||||||
if (tmuxBufferLoaded) {
|
if (tmuxBufferLoaded) {
|
||||||
return tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`)
|
return emitSequence ? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return raw
|
return emitSequence ? raw : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// Linux clipboard tool: undefined = not yet probed, null = none available.
|
// Linux clipboard tool: undefined = not yet probed, null = none available.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue