mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
fix(tui): termux-gate composer rendering tweaks for Ink TUI
Salvaged from #28942 (adybag14-cyber). Only the Ink TUI half is taken here — the bundled "termux compatibility note" added to skills_tool.py in the original PR did not address the actual user-reported bug (skill_matches_platform() filtering Linux skills out on Termux) and also regressed the EXCLUDED_SKILL_DIRS set used to prune nested .venv/site-packages skills. Changes: - ui-tui/src/lib/prompt.ts: single-cell ASCII '>' marker in Termux mode to avoid ambiguous-width glyph artifacts while typing. - ui-tui/src/components/appLayout.tsx: suppress profile prefix on narrow Termux panes (>=90 cols still shows it). - ui-tui/src/lib/inputMetrics.ts + components/messageLine.tsx + lib/virtualHeights.ts: termux-aware transcript body width — drop the desktop 20-col floor on narrow mobile layouts, align virtual heights with actual rendered width. - ui-tui/src/components/textInput.tsx: disable fast-echo bypass by default in Termux to avoid ghosting at soft-wrap boundaries. HERMES_TUI_TERMUX_FAST_ECHO=1 opts back in. Tests: ui-tui/src/__tests__/{prompt,termuxComposerLayout,textInputFastEcho}.test.ts (12 PR-added tests pass; 3 pre-existing wrapAnsi-bundling failures on main are unrelated.) The real skill-listing fix on Termux ('android' platform matching Linux skills) ships as a follow-up commit on this branch.
This commit is contained in:
parent
0e2873a77d
commit
d08c2a016a
9 changed files with 134 additions and 13 deletions
|
|
@ -16,4 +16,16 @@ describe('composerPromptText', () => {
|
|||
expect(composerPromptText('❯', 'custom')).toBe('❯')
|
||||
expect(composerPromptText('❯')).toBe('❯')
|
||||
})
|
||||
|
||||
it('uses a Termux-safe ASCII prompt marker in normal mode', () => {
|
||||
expect(composerPromptText('❯', 'coder', false, true, 50)).toBe('>')
|
||||
})
|
||||
|
||||
it('keeps profile prefix suppressed on narrow Termux widths', () => {
|
||||
expect(composerPromptText('❯', 'upstr', false, true, 72)).toBe('>')
|
||||
})
|
||||
|
||||
it('allows profile prefix on very wide Termux panes', () => {
|
||||
expect(composerPromptText('❯', 'upstr', false, true, 120)).toBe('upstr >')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
40
ui-tui/src/__tests__/termuxComposerLayout.test.ts
Normal file
40
ui-tui/src/__tests__/termuxComposerLayout.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { stableComposerColumns, transcriptBodyWidth } from '../lib/inputMetrics.js'
|
||||
import { composerPromptText } from '../lib/prompt.js'
|
||||
|
||||
describe('Termux composer prompt + width guards', () => {
|
||||
it('uses a single-cell ASCII prompt marker in Termux mode', () => {
|
||||
expect(composerPromptText('❯', 'coder', false, true, 50)).toBe('>')
|
||||
})
|
||||
|
||||
it('suppresses profile prefixes on narrow Termux panes', () => {
|
||||
expect(composerPromptText('❯', 'upstr', false, true, 72)).toBe('>')
|
||||
})
|
||||
|
||||
it('keeps profile context on very wide Termux panes', () => {
|
||||
expect(composerPromptText('❯', 'upstr', false, true, 120)).toBe('upstr >')
|
||||
})
|
||||
|
||||
it('reserves fewer columns for gutter on narrow Termux widths', () => {
|
||||
// 32 columns after prompt: desktop reserves 2 for transcript scrollbar,
|
||||
// Termux keeps those 2 columns for the active composer.
|
||||
expect(stableComposerColumns(40, 8, false)).toBe(28)
|
||||
expect(stableComposerColumns(40, 8, true)).toBe(30)
|
||||
|
||||
// With ample room, Termux still reserves the gutter for alignment.
|
||||
expect(stableComposerColumns(60, 8, true)).toBe(48)
|
||||
})
|
||||
|
||||
it('never over-allocates transcript body width on narrow panes', () => {
|
||||
// Old behavior hard-minned to 20 columns and overflowed narrow layouts.
|
||||
expect(transcriptBodyWidth(24, 'assistant', '>', true)).toBe(19)
|
||||
expect(transcriptBodyWidth(24, 'user', 'upstr >', true)).toBe(14)
|
||||
expect(transcriptBodyWidth(10, 'user', '>', true)).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('keeps legacy desktop floor outside Termux mode', () => {
|
||||
expect(transcriptBodyWidth(24, 'assistant', '>')).toBe(20)
|
||||
expect(transcriptBodyWidth(24, 'user', 'upstr >')).toBe(20)
|
||||
})
|
||||
})
|
||||
|
|
@ -178,7 +178,22 @@ describe('supportsFastEchoTerminal', () => {
|
|||
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps fast-echo enabled in VS Code and unknown terminals', () => {
|
||||
it('disables fast-echo by default in Termux mode', () => {
|
||||
expect(
|
||||
supportsFastEchoTerminal({ TERMUX_VERSION: '0.118.0', PREFIX: '/data/data/com.termux/files/usr' } as NodeJS.ProcessEnv)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('allows explicit Termux fast-echo opt-in via env override', () => {
|
||||
expect(
|
||||
supportsFastEchoTerminal({
|
||||
HERMES_TUI_TERMUX_FAST_ECHO: '1',
|
||||
TERMUX_VERSION: '0.118.0'
|
||||
} as NodeJS.ProcessEnv)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps fast-echo enabled in VS Code and unknown non-Termux terminals', () => {
|
||||
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
expect(supportsFastEchoTerminal({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useGateway } from '../app/gatewayContext.js'
|
|||
import type { AppLayoutProps } from '../app/interfaces.js'
|
||||
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import { INLINE_MODE, SHOW_FPS } from '../config/env.js'
|
||||
import { INLINE_MODE, SHOW_FPS, TERMUX_TUI_MODE } from '../config/env.js'
|
||||
import { PLACEHOLDER } from '../content/placeholders.js'
|
||||
import {
|
||||
COMPOSER_PROMPT_GAP_WIDTH,
|
||||
|
|
@ -169,10 +169,10 @@ const ComposerPane = memo(function ComposerPane({
|
|||
const ui = useStore($uiState)
|
||||
const isBlocked = useStore($isBlocked)
|
||||
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
|
||||
const promptText = composerPromptText(ui.theme.brand.prompt, ui.info?.profile_name, sh)
|
||||
const promptText = composerPromptText(ui.theme.brand.prompt, ui.info?.profile_name, sh, TERMUX_TUI_MODE, composer.cols)
|
||||
const promptWidth = composerPromptWidth(promptText)
|
||||
const promptBlank = ' '.repeat(promptWidth)
|
||||
const inputColumns = stableComposerColumns(composer.cols, promptWidth)
|
||||
const inputColumns = stableComposerColumns(composer.cols, promptWidth, TERMUX_TUI_MODE)
|
||||
const inputHeight = inputVisualHeight(composer.input, inputColumns)
|
||||
const inputMouseRef = useRef<null | TextInputMouseApi>(null)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Ansi, Box, NoSelect, Text } from '@hermes/ink'
|
||||
import { memo, useState } from 'react'
|
||||
|
||||
import { TERMUX_TUI_MODE } from '../config/env.js'
|
||||
import { LONG_MSG } from '../config/limits.js'
|
||||
import { sectionMode } from '../domain/details.js'
|
||||
import { userDisplay } from '../domain/messages.js'
|
||||
|
|
@ -139,7 +140,7 @@ export const MessageLine = memo(function MessageLine({
|
|||
}
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
const bodyWidth = transcriptBodyWidth(cols, msg.role, t.brand.prompt)
|
||||
const bodyWidth = transcriptBodyWidth(cols, msg.role, t.brand.prompt, TERMUX_TUI_MODE)
|
||||
|
||||
return isStreaming ? (
|
||||
// Incremental markdown: split at the last stable block boundary so
|
||||
|
|
@ -201,7 +202,7 @@ export const MessageLine = memo(function MessageLine({
|
|||
</Text>
|
||||
</NoSelect>
|
||||
|
||||
<Box width={transcriptBodyWidth(cols, msg.role, t.brand.prompt)}>{content}</Box>
|
||||
<Box width={transcriptBodyWidth(cols, msg.role, t.brand.prompt, TERMUX_TUI_MODE)}>{content}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
isVoiceToggleKey,
|
||||
type ParsedVoiceRecordKey
|
||||
} from '../lib/platform.js'
|
||||
import { isTermuxTuiMode } from '../lib/termux.js'
|
||||
|
||||
type InkExt = typeof Ink & {
|
||||
stringWidth: (s: string) => number
|
||||
|
|
@ -298,7 +299,23 @@ export function canFastBackspaceShape(current: string, cursor: number, columns?:
|
|||
export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
// Terminal.app still shows paint/cursor artifacts under the fast-echo
|
||||
// bypass path. Fall back to the normal Ink render path there.
|
||||
return (env.TERM_PROGRAM ?? '').trim() !== 'Apple_Terminal'
|
||||
if ((env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Termux terminals are especially sensitive to bypass-path cursor drift and
|
||||
// stale paints at soft-wrap boundaries on tall/narrow viewports. Keep this
|
||||
// off by default in Termux mode; allow explicit opt-in for local debugging.
|
||||
if (isTermuxTuiMode(env)) {
|
||||
const override = String(env.HERMES_TUI_TERMUX_FAST_ECHO ?? '').trim().toLowerCase()
|
||||
if (override) {
|
||||
return /^(?:1|true|yes|on)$/i.test(override)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function renderWithCursor(value: string, cursor: number) {
|
||||
|
|
|
|||
|
|
@ -177,14 +177,25 @@ export function transcriptGutterWidth(role: Role, userPrompt: string) {
|
|||
return role === 'user' ? composerPromptWidth(userPrompt) : 3
|
||||
}
|
||||
|
||||
export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string) {
|
||||
return Math.max(20, totalCols - transcriptGutterWidth(role, userPrompt) - 2)
|
||||
export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string, termuxMode = false) {
|
||||
const available = Math.max(1, totalCols - transcriptGutterWidth(role, userPrompt) - 2)
|
||||
|
||||
if (termuxMode) {
|
||||
// On narrow / unusual aspect-ratio mobile panes, forcing a wide minimum
|
||||
// width causes right-edge clipping and chopped words.
|
||||
return available
|
||||
}
|
||||
|
||||
return Math.max(20, available)
|
||||
}
|
||||
|
||||
export function stableComposerColumns(totalCols: number, promptWidth: number) {
|
||||
export function stableComposerColumns(totalCols: number, promptWidth: number, termuxMode = false) {
|
||||
// Physical render/wrap width. Always reserve outer composer padding and
|
||||
// prompt prefix. Only reserve the transcript scrollbar gutter when the
|
||||
// terminal is wide enough; on narrow panes, preserving input columns beats
|
||||
// keeping gutters visually aligned.
|
||||
return Math.max(1, totalCols - promptWidth - 2 - (totalCols - promptWidth >= 24 ? 2 : 0))
|
||||
const afterPrompt = totalCols - promptWidth
|
||||
const reserveScrollbar = afterPrompt >= (termuxMode ? 36 : 24) ? 2 : 0
|
||||
|
||||
return Math.max(1, totalCols - promptWidth - 2 - reserveScrollbar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,32 @@
|
|||
export function composerPromptText(prompt: string, profileName?: null | string, shellMode = false): string {
|
||||
const TERMUX_SAFE_PROMPT = '>'
|
||||
|
||||
export function composerPromptText(
|
||||
prompt: string,
|
||||
profileName?: null | string,
|
||||
shellMode = false,
|
||||
termuxMode = false,
|
||||
totalCols?: number
|
||||
): string {
|
||||
if (shellMode) {
|
||||
return '$'
|
||||
}
|
||||
|
||||
if (termuxMode) {
|
||||
// Termux fonts/terminal backends can render decorative prompt glyphs with
|
||||
// ambiguous width; keep the live composer marker strictly single-cell ASCII
|
||||
// so we never leave stale arrow artifacts while typing.
|
||||
const basePrompt = TERMUX_SAFE_PROMPT
|
||||
|
||||
// On very wide panes we can still include profile context. On narrow/mobile
|
||||
// panes this burns precious columns and increases wrap/clipping risk.
|
||||
const wideEnoughForProfile = typeof totalCols === 'number' ? totalCols >= 90 : false
|
||||
if (wideEnoughForProfile && profileName && !['default', 'custom'].includes(profileName)) {
|
||||
return `${profileName} ${basePrompt}`
|
||||
}
|
||||
|
||||
return basePrompt
|
||||
}
|
||||
|
||||
if (profileName && !['default', 'custom'].includes(profileName)) {
|
||||
return `${profileName} ${prompt}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Msg } from '../types.js'
|
||||
|
||||
import { TERMUX_TUI_MODE } from '../config/env.js'
|
||||
import { transcriptBodyWidth } from './inputMetrics.js'
|
||||
|
||||
const hashText = (text: string) => {
|
||||
|
|
@ -96,7 +97,7 @@ export const estimatedMsgHeight = (
|
|||
return Math.max(2, msg.todos.length + 2)
|
||||
}
|
||||
|
||||
const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt)
|
||||
const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt, TERMUX_TUI_MODE)
|
||||
const text = msg.text
|
||||
let h = wrappedLines(text || ' ', bodyWidth)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue