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:
adybag14-cyber 2026-05-21 17:22:14 -07:00 committed by Teknium
parent 0e2873a77d
commit d08c2a016a
9 changed files with 134 additions and 13 deletions

View file

@ -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 >')
})
})

View 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)
})
})

View file

@ -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)
})

View file

@ -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)

View file

@ -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>
)

View file

@ -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) {

View file

@ -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)
}

View file

@ -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}`
}

View file

@ -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)