From d08c2a016ab369b68388429cd13d44a364c94585 Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Thu, 21 May 2026 17:22:14 -0700 Subject: [PATCH] fix(tui): termux-gate composer rendering tweaks for Ink TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui/src/__tests__/prompt.test.ts | 12 ++++++ .../__tests__/termuxComposerLayout.test.ts | 40 +++++++++++++++++++ .../src/__tests__/textInputFastEcho.test.ts | 17 +++++++- ui-tui/src/components/appLayout.tsx | 6 +-- ui-tui/src/components/messageLine.tsx | 5 ++- ui-tui/src/components/textInput.tsx | 19 ++++++++- ui-tui/src/lib/inputMetrics.ts | 19 +++++++-- ui-tui/src/lib/prompt.ts | 26 +++++++++++- ui-tui/src/lib/virtualHeights.ts | 3 +- 9 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 ui-tui/src/__tests__/termuxComposerLayout.test.ts diff --git a/ui-tui/src/__tests__/prompt.test.ts b/ui-tui/src/__tests__/prompt.test.ts index 7b923c79a40..68c57354783 100644 --- a/ui-tui/src/__tests__/prompt.test.ts +++ b/ui-tui/src/__tests__/prompt.test.ts @@ -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 >') + }) }) diff --git a/ui-tui/src/__tests__/termuxComposerLayout.test.ts b/ui-tui/src/__tests__/termuxComposerLayout.test.ts new file mode 100644 index 00000000000..e845ef89c3f --- /dev/null +++ b/ui-tui/src/__tests__/termuxComposerLayout.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/__tests__/textInputFastEcho.test.ts b/ui-tui/src/__tests__/textInputFastEcho.test.ts index 83b5c511940..6221314a062 100644 --- a/ui-tui/src/__tests__/textInputFastEcho.test.ts +++ b/ui-tui/src/__tests__/textInputFastEcho.test.ts @@ -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) }) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index a4b6963cb5a..2e35c75c307 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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) diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index d44e29c1206..4d1481373ab 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -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({ - {content} + {content} ) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 92082280a04..8c9e5213b13 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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) { diff --git a/ui-tui/src/lib/inputMetrics.ts b/ui-tui/src/lib/inputMetrics.ts index 4c624da167a..860b7455a37 100644 --- a/ui-tui/src/lib/inputMetrics.ts +++ b/ui-tui/src/lib/inputMetrics.ts @@ -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) } diff --git a/ui-tui/src/lib/prompt.ts b/ui-tui/src/lib/prompt.ts index 15607b61362..10961b90312 100644 --- a/ui-tui/src/lib/prompt.ts +++ b/ui-tui/src/lib/prompt.ts @@ -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}` } diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts index 0e58b814d12..874f8a1b8dc 100644 --- a/ui-tui/src/lib/virtualHeights.ts +++ b/ui-tui/src/lib/virtualHeights.ts @@ -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)