diff --git a/ui-tui/src/__tests__/statusRule.test.ts b/ui-tui/src/__tests__/statusRule.test.ts new file mode 100644 index 00000000000..635b35db996 --- /dev/null +++ b/ui-tui/src/__tests__/statusRule.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' + +import { statusRuleWidths } from '../components/appChrome.js' + +describe('statusRuleWidths', () => { + it('keeps the status rule within the terminal width', () => { + for (const cols of [8, 12, 20, 40, 100]) { + const widths = statusRuleWidths(cols, '~/src/hermes-agent/main (some-long-branch-name)') + + expect(widths.leftWidth + widths.separatorWidth + widths.rightWidth).toBeLessThanOrEqual(cols) + expect(widths.leftWidth).toBeGreaterThan(0) + } + }) + + it('truncates the cwd segment before it can wrap in skinny terminals', () => { + const widths = statusRuleWidths(24, '~/src/hermes-agent/main (bb/some-extremely-long-branch)') + + expect(widths.rightWidth).toBeLessThan('~/src/hermes-agent/main (bb/some-extremely-long-branch)'.length) + expect(widths.leftWidth).toBeGreaterThanOrEqual(8) + }) + + it('omits the cwd segment when there is no room for it', () => { + expect(statusRuleWidths(2, 'abcdef')).toEqual({ leftWidth: 2, rightWidth: 0, separatorWidth: 0 }) + }) + + it('budgets the cwd segment by display width, not utf-16 length', () => { + const widths = statusRuleWidths(30, '目录/分支') + + expect(widths.leftWidth + widths.separatorWidth + widths.rightWidth).toBeLessThanOrEqual(30) + expect(widths.rightWidth).toBeGreaterThan('目录/分支'.length) + }) +}) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index c961f4c2731..771c917691f 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -1,4 +1,4 @@ -import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' +import { Box, type ScrollBoxHandle, stringWidth, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react' import unicodeSpinners from 'unicode-animations' @@ -150,6 +150,23 @@ function ctxBar(pct: number | undefined, w = 10) { return '█'.repeat(filled) + '░'.repeat(w - filled) } +export function statusRuleWidths(cols: number, cwdLabel: string) { + const width = Math.max(1, Math.floor(cols || 1)) + const desiredSeparatorWidth = width >= 24 ? 3 : 1 + const minLeftWidth = width >= 24 ? 8 : 1 + const maxRightWidth = Math.max(0, width - desiredSeparatorWidth - minLeftWidth) + + if (!cwdLabel || maxRightWidth <= 0) { + return { leftWidth: width, rightWidth: 0, separatorWidth: 0 } + } + + const rightWidth = Math.max(0, Math.min(stringWidth(cwdLabel), maxRightWidth)) + const separatorWidth = rightWidth > 0 ? desiredSeparatorWidth : 0 + const leftWidth = Math.max(1, width - separatorWidth - rightWidth) + + return { leftWidth, rightWidth, separatorWidth } +} + function SpawnHud({ t }: { t: Theme }) { // Tight HUD that only appears when the session is actually fanning out. // Colour escalates to warn/error as depth or concurrency approaches the cap. @@ -297,7 +314,7 @@ export function StatusRule({ : '' const bar = usage.context_max ? ctxBar(pct) : '' - const leftWidth = Math.max(12, cols - cwdLabel.length - 3) + const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, cwdLabel) return ( @@ -349,8 +366,16 @@ export function StatusRule({ - - {cwdLabel} + {rightWidth > 0 ? ( + <> + {separatorWidth >= 3 ? ' ─ ' : ' '} + + + {cwdLabel} + + + + ) : null} ) }