diff --git a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx index 15e896cb9c5..4f2604be0ec 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx @@ -48,10 +48,10 @@ export type ScrollBoxHandle = { */ isSticky: () => boolean /** - * Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom). - * Does NOT fire for stickyScroll updates done by the Ink renderer — those - * happen during Ink's render phase after React has committed. Callers that - * care about the sticky case should treat "at bottom" as a fallback. + * Subscribe to scroll viewport changes. Fires for imperative scroll changes + * (scrollTo/scrollBy/scrollToBottom) and for renderer-computed scroll bounds + * changes such as content growth or terminal resize. Callers use this to + * keep virtualized ranges aligned with the currently visible viewport. */ subscribe: (listener: () => void) => () => void /** diff --git a/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts b/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts index a11a028e771..c0935587d0f 100644 --- a/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts @@ -42,7 +42,8 @@ const stdoutOnly = (diff: ReturnType) => .map(p => (p as { type: 'stdout'; content: string }).content) .join('') -const hasDecstbm = (text: string) => /\x1b\[\d+;\d+r/.test(text) +const ESC = '\u001b' +const hasDecstbm = (text: string) => new RegExp(`${ESC}\\[\\d+;\\d+r`).test(text) describe('LogUpdate.render diff contract', () => { it('emits only changed cells when most rows match', () => { @@ -87,6 +88,25 @@ describe('LogUpdate.render diff contract', () => { expect(stdoutOnly(diff)).toContain('shorterrownow') }) + it('height growth emits a clearTerminal patch before repainting', () => { + const w = 20 + const prevH = 3 + const nextH = 6 + + const prev = mkScreen(w, prevH) + paint(prev, 0, 'old rows') + + const next = mkScreen(w, nextH) + paint(next, 0, 'new rows') + next.damage = { x: 0, y: 0, width: w, height: nextH } + + const log = new LogUpdate({ isTTY: true, stylePool }) + const diff = log.render(mkFrame(prev, w, prevH), mkFrame(next, w, nextH), true, false) + + expect(diff.some(p => p.type === 'clearTerminal')).toBe(true) + expect(stdoutOnly(diff)).toContain('newrows') + }) + it('drift repro: identical prev/next emits no heal, even when the physical terminal is stale', () => { // Load-bearing theory for the rapid-resize scattered-letter bug: if the // physical terminal has stale cells that prev.screen doesn't know about @@ -167,10 +187,12 @@ describe('LogUpdate.render diff contract', () => { paint(next, 1, 'row one') const prevFrame = mkFrame(prev, w, h) + const nextFrame: Frame = { ...mkFrame(next, w, h), scrollHint: { top: 1, bottom: 4, delta: 1 } } + const log = new LogUpdate({ isTTY: true, stylePool }) const diff = log.render(prevFrame, nextFrame, true, true) @@ -187,10 +209,12 @@ describe('LogUpdate.render diff contract', () => { paint(next, 1, 'row one') const prevFrame = mkFrame(prev, w, h) + const nextFrame: Frame = { ...mkFrame(next, w, h), scrollHint: { top: 1, bottom: 5, delta: 1 } } + const log = new LogUpdate({ isTTY: true, stylePool }) const diff = log.render(prevFrame, nextFrame, true, true) diff --git a/ui-tui/packages/hermes-ink/src/ink/log-update.ts b/ui-tui/packages/hermes-ink/src/ink/log-update.ts index 0f36d4641e7..a428060b97d 100644 --- a/ui-tui/packages/hermes-ink/src/ink/log-update.ts +++ b/ui-tui/packages/hermes-ink/src/ink/log-update.ts @@ -141,14 +141,12 @@ export class LogUpdate { const startTime = performance.now() const stylePool = this.options.stylePool - // Since we assume the cursor is at the bottom on the screen, we only need - // to clear when the viewport gets shorter (i.e. the cursor position drifts) - // or when it gets thinner (and text wraps). We _could_ figure out how to - // not reset here but that would involve predicting the current layout - // _after_ the viewport change which means calcuating text wrapping. - // Resizing is a rare enough event that it's not practically a big issue. + // Terminal hosts can reflow/preserve old cells on any resize, including + // height-only growth. A partial diff can then leave stale transcript rows + // or cut off bordered content even when our virtual scrollTop is correct. + // Resizing is rare enough that a full repaint is the safer tradeoff. if ( - next.viewport.height < prev.viewport.height || + next.viewport.height !== prev.viewport.height || (prev.viewport.width !== 0 && next.viewport.width !== prev.viewport.width) ) { return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool) diff --git a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts index a31753c722a..5fee72cccaf 100644 --- a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts @@ -706,12 +706,22 @@ function renderNodeToOutput( const content = node.childNodes.find(c => (c as DOMElement).yogaNode) as DOMElement | undefined const contentYoga = content?.yogaNode - // scrollHeight is the intrinsic height of the content wrapper. - // Do NOT add getComputedTop() — that's the wrapper's offset - // within the viewport (equal to the scroll container's - // paddingTop), and innerHeight already subtracts padding, so - // including it double-counts padding and inflates maxScroll. - const scrollHeight = contentYoga?.getComputedHeight() ?? 0 + // scrollHeight is the intrinsic height of the content wrapper, but + // after terminal resizes Yoga can leave tall descendants overflowing + // that wrapper. Use the deepest direct child bottom so sticky-bottom + // math can still reach the real final rendered row. + let scrollHeight = Math.ceil(contentYoga?.getComputedHeight() ?? 0) + + if (content) { + for (const child of content.childNodes) { + const childYoga = (child as DOMElement).yogaNode + + if (childYoga) { + scrollHeight = Math.max(scrollHeight, Math.ceil(childYoga.getComputedTop() + childYoga.getComputedHeight())) + } + } + } + // Capture previous scroll bounds BEFORE overwriting — the at-bottom // follow check compares against last frame's max. const prevScrollHeight = node.scrollHeight ?? scrollHeight @@ -862,7 +872,12 @@ function renderNodeToOutput( scrollDrainNode = node } - if ((node.scrollTop ?? 0) !== scrollTopBeforeFollow || node.stickyScroll !== stickyBeforeFollow) { + if ( + (node.scrollTop ?? 0) !== scrollTopBeforeFollow || + node.stickyScroll !== stickyBeforeFollow || + scrollHeight !== prevScrollHeight || + innerHeight !== prevInnerHeight + ) { node.notifyScrollChange?.() } @@ -891,7 +906,14 @@ function renderNodeToOutput( const regionTop = Math.floor(y + contentYoga.getComputedTop()) const regionBottom = regionTop + innerHeight - 1 - if (cached?.y === y && cached.height === height && innerHeight > 0 && Math.abs(delta) < innerHeight) { + if ( + cached?.x === x && + cached.y === y && + cached.width === width && + cached.height === height && + innerHeight > 0 && + Math.abs(delta) < innerHeight + ) { hint = { top: regionTop, bottom: regionBottom, delta } scrollHint = hint } else { diff --git a/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts b/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts index 5a3e8cd0976..a98b43972e6 100644 --- a/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts +++ b/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts @@ -4,10 +4,11 @@ import { Box, renderSync, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ import React, { useLayoutEffect, useRef } from 'react' import { describe, expect, it } from 'vitest' -import { useVirtualHistory } from '../hooks/useVirtualHistory.js' +import { useVirtualHistory, virtualHistorySnapshotKey } from '../hooks/useVirtualHistory.js' interface Item { height: number + heightAfterResize?: number key: string } @@ -49,13 +50,28 @@ const viewportIsMounted = (items: readonly Item[], virtualHistory: ReturnType= span.top && bottom <= span.bottom } -function Harness({ expose, items }: { expose: React.MutableRefObject; items: readonly Item[] }) { +const itemHeightForColumns = (item: Item | undefined, columns: number) => + columns >= 80 ? (item?.heightAfterResize ?? item?.height ?? 1) : (item?.height ?? 1) + +function Harness({ + columns = 80, + expose, + height = 10, + items, + maxMounted = 16 +}: { + columns?: number + expose: React.MutableRefObject + height?: number + items: readonly Item[] + maxMounted?: number +}) { const scrollRef = useRef(null) - const virtualHistory = useVirtualHistory(scrollRef, items, 80, { + const virtualHistory = useVirtualHistory(scrollRef, items, columns, { coldStartCount: 16, - estimateHeight: index => items[index]?.height ?? 1, - maxMounted: 16, + estimateHeight: index => itemHeightForColumns(items[index], columns), + maxMounted, overscan: 2 }) @@ -65,7 +81,7 @@ function Harness({ expose, items }: { expose: React.MutableRefObject React.createElement( Box, - { height: item.height, key: item.key, ref: virtualHistory.measureRef(item.key) }, + { + height: itemHeightForColumns(item, columns), + key: item.key, + ref: virtualHistory.measureRef(item.key) + }, React.createElement(Text, null, item.key) ) ), @@ -85,6 +105,113 @@ function Harness({ expose, items }: { expose: React.MutableRefObject { + it('includes viewport height in the external-store snapshot key', () => { + const base = { + getPendingDelta: () => 0, + getScrollTop: () => 20, + isSticky: () => false + } + + const short = virtualHistorySnapshotKey({ + ...base, + getViewportHeight: () => 5 + } as ScrollBoxHandle) + + const tall = virtualHistorySnapshotKey({ + ...base, + getViewportHeight: () => 25 + } as ScrollBoxHandle) + + expect(short).not.toBe(tall) + }) + + it('remounts enough tail rows after the scroll viewport grows', async () => { + const items = Array.from({ length: 100 }, (_, index) => ({ height: 1, key: `item-${index}` })) + const expose = { current: null as Exposed | null } + const streams = makeStreams() + + const instance = renderSync(React.createElement(Harness, { expose, height: 4, items, maxMounted: 80 }), { + patchConsole: false, + stderr: streams.stderr as NodeJS.WriteStream, + stdin: streams.stdin as NodeJS.ReadStream, + stdout: streams.stdout as NodeJS.WriteStream + }) + + try { + await delay(20) + instance.rerender(React.createElement(Harness, { expose, height: 9, items, maxMounted: 80 })) + await delay(80) + + expect(viewportIsMounted(items, expose.current!.virtualHistory, expose.current!.scroll!)).toBe(true) + } finally { + instance.unmount() + instance.cleanup() + } + }) + + it('recomputes tail coverage when wrapped rows shrink after a width resize', async () => { + const items = Array.from({ length: 100 }, (_, index) => ({ + height: 4, + heightAfterResize: 1, + key: `item-${index}` + })) + + const expose = { current: null as Exposed | null } + const streams = makeStreams() + + const instance = renderSync( + React.createElement(Harness, { columns: 40, expose, height: 10, items, maxMounted: 80 }), + { + patchConsole: false, + stderr: streams.stderr as NodeJS.WriteStream, + stdin: streams.stdin as NodeJS.ReadStream, + stdout: streams.stdout as NodeJS.WriteStream + } + ) + + try { + await delay(20) + instance.rerender(React.createElement(Harness, { columns: 80, expose, height: 10, items, maxMounted: 80 })) + await delay(80) + + const resizedItems = items.map(item => ({ height: item.heightAfterResize!, key: item.key })) + + expect(viewportIsMounted(resizedItems, expose.current!.virtualHistory, expose.current!.scroll!)).toBe(true) + } finally { + instance.unmount() + instance.cleanup() + } + }) + + it('keeps sticky scroll at the bottom when one tall tail row resizes', async () => { + const items = [{ height: 90, heightAfterResize: 50, key: 'tail' }] + const expose = { current: null as Exposed | null } + const streams = makeStreams() + + const instance = renderSync( + React.createElement(Harness, { columns: 70, expose, height: 18, items, maxMounted: 80 }), + { + patchConsole: false, + stderr: streams.stderr as NodeJS.WriteStream, + stdin: streams.stdin as NodeJS.ReadStream, + stdout: streams.stdout as NodeJS.WriteStream + } + ) + + try { + await delay(20) + instance.rerender(React.createElement(Harness, { columns: 120, expose, height: 36, items, maxMounted: 80 })) + await delay(80) + + const scroll = expose.current!.scroll! + + expect(scroll.getScrollTop()).toBe(scroll.getScrollHeight() - scroll.getViewportHeight()) + } finally { + instance.unmount() + instance.cleanup() + } + }) + it('recomputes offsets after a mounted row height changes', async () => { const tall = [ { height: 6, key: 'a' }, diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index fde5231c278..4d7ab8926ba 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -234,9 +234,15 @@ export function useMainApp(gw: GatewayClient) { return next }, []) + // Wrapped row heights are width-dependent. Cached layout outlives a resize + // and lands sticky-scroll at the stale max, cutting off the tail. The + // hook's "scale heights by oldCols/newCols" path is too approximate for + // mixed markdown — we deliberately remount every row so yoga re-measures + // off live geometry. Cost: per-row local state (e.g. systemOpen toggles) + // resets on resize; small UX hit for a hard correctness win. const virtualRows = useMemo( - () => historyItems.map((msg, index) => ({ index, key: messageId(msg), msg })), - [historyItems, messageId] + () => historyItems.map((msg, index) => ({ index, key: `${messageId(msg)}:c${cols}`, msg })), + [cols, historyItems, messageId] ) const detailsLayoutKey = useMemo(() => { @@ -425,10 +431,20 @@ export function useMainApp(gw: GatewayClient) { let timer: ReturnType | undefined + // Resize reflows wrapped lines; if the user is still pinned to the tail + // we need to re-snap once React has remeasured. virtualRows is keyed on + // cols so every column change forces a fresh measurement pass before + // this timer fires. Re-check isSticky() inside the timeout — a manual + // scroll during the 100ms window otherwise yanks the user back to tail. const onResize = () => { clearTimeout(timer) timer = setTimeout(() => { timer = undefined + + if (scrollRef.current?.isSticky()) { + scrollRef.current.scrollToBottom() + } + void rpc('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid }) }, 100) } diff --git a/ui-tui/src/banner.ts b/ui-tui/src/banner.ts index 80da8f43d70..748e5a452bc 100644 --- a/ui-tui/src/banner.ts +++ b/ui-tui/src/banner.ts @@ -79,8 +79,8 @@ const colorize = (art: string[], gradient: readonly number[], c: ThemeColors): L return art.map((text, i) => [p[gradient[i]!] ?? c.muted, text]) } -export const LOGO_WIDTH = 98 -export const CADUCEUS_WIDTH = 30 +export const LOGO_WIDTH = Math.max(...LOGO_ART.map(line => line.length)) +export const CADUCEUS_WIDTH = Math.max(...CADUCEUS_ART.map(line => line.length)) export const logo = (c: ThemeColors, customLogo?: string): Line[] => customLogo ? parseRichMarkup(customLogo) : colorize(LOGO_ART, LOGO_GRADIENT, c) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 2e35c75c307..8b69b9e4425 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -112,9 +112,9 @@ const TranscriptPane = memo(function TranscriptPane({ {row.msg.kind === 'intro' ? ( - + - {row.msg.info && } + {row.msg.info && } ) : row.msg.kind === 'panel' && row.msg.panelData ? ( diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index b7590f695e8..4f2bbb5eae5 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -29,31 +29,92 @@ function InlineLoader({ label, t }: { label: string; t: Theme }) { export function ArtLines({ lines }: { lines: [string, string][] }) { return ( - <> + {lines.map(([c, text], i) => ( - + {text} ))} - + ) } -export function Banner({ t }: { t: Theme }) { - const cols = useStdout().stdout?.columns ?? 80 +// Responsive Banner: full art → compact rule → text → hidden. +// +// Terminals can't scale glyphs, so "responsive" means picking a layout that +// fits the available columns. Thresholds are picked so each tier reads +// comfortably without forcing wrap or truncation drift on box-drawing edges. +const TAG_FULL = 'Nous Research · Messenger of the Digital Gods' +const TAG_MID = 'Messenger of the Digital Gods' +const TAG_TINY = 'Nous Research' +const HIDE_BELOW = 34 +const COMPACT_FROM = 58 + +const clip = (s: string, w: number) => + w <= 0 ? '' : s.length > w ? `${s.slice(0, Math.max(0, w - 1))}…` : s + +const centerIn = (s: string, w: number) => { + const f = clip(s, w) + const slack = Math.max(0, w - f.length) + const left = slack >> 1 + + return `${' '.repeat(left)}${f}${' '.repeat(slack - left)}` +} + +const ruleIn = (label: string, w: number) => { + const f = clip(label, Math.max(1, w - 4)) + const slack = Math.max(0, w - f.length - 2) + const left = slack >> 1 + + return `${'─'.repeat(left)} ${f} ${'─'.repeat(slack - left)}` +} + +function CompactBanner({ cols, t }: { cols: number; t: Theme }) { + // -4 keeps a margin so exact-edge rows don't trip terminal pending-wrap. + const w = Math.max(28, cols - 4) + + return ( + + {ruleIn(t.brand.name, w)} + {centerIn(TAG_FULL, w)} + {'─'.repeat(w)} + + ) +} + +export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) { + const term = useStdout().stdout?.columns ?? 80 + const cols = Math.max(1, Math.min(term, maxWidth ?? term)) + + if (cols < HIDE_BELOW) { + return null + } + const logoLines = logo(t.color, t.bannerLogo || undefined) + const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH + + if (cols >= logoW + 2) { + return ( + + + + {t.brand.icon} {TAG_FULL} + + + ) + } + + if (cols >= COMPACT_FROM) { + return + } + + const name = cols >= 52 ? t.brand.name : (t.brand.name.split(' ')[0] ?? t.brand.name) + const tag = cols >= 64 ? TAG_FULL : cols >= 46 ? TAG_MID : TAG_TINY return ( - {cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? ( - - ) : ( - - {t.brand.icon} NOUS HERMES - - )} - - {t.brand.icon} Nous Research · Messenger of the Digital Gods + {t.brand.icon} {name} + {t.brand.icon} {tag} ) } @@ -96,8 +157,9 @@ function CollapseToggle({ const SKILLS_MAX = 8 const TOOLSETS_MAX = 8 -export function SessionPanel({ info, sid, t }: SessionPanelProps) { - const cols = useStdout().stdout?.columns ?? 100 +export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { + const term = useStdout().stdout?.columns ?? 100 + const cols = Math.max(20, Math.min(term, maxWidth ?? term)) const heroLines = caduceus(t.color, t.bannerHero || undefined) const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4)) const wide = cols >= 90 && leftW + 40 < cols @@ -241,13 +303,33 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) { )} - - - {t.brand.name} - {info.version ? ` v${info.version}` : ''} - {info.release_date ? ` (${info.release_date})` : ''} - - + {wide ? ( + + + {t.brand.name} + {info.version ? ` v${info.version}` : ''} + {info.release_date ? ` (${info.release_date})` : ''} + + + ) : ( + // Narrow layout hides the hero column; surface model/cwd/session + // here so they aren't lost. + + + {info.model.split('/').pop()} + · Nous Research + + + {info.cwd || process.cwd()} + + {sid && ( + + Session: + {sid} + + )} + + )} {/* ── Tools (expanded by default) ── */} @@ -378,6 +460,7 @@ interface PanelProps { interface SessionPanelProps { info: SessionInfo + maxWidth?: number sid?: string | null t: Theme } diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index ef96ae1078c..592d20e9a07 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -51,6 +51,18 @@ const SLIDE_STEP = 12 const NOOP = () => {} +export const virtualHistorySnapshotKey = (s?: ScrollBoxHandle | null): string => { + if (!s) { + return 'none' + } + + const target = s.getScrollTop() + s.getPendingDelta() + const bin = Math.floor(target / QUANTUM) + const viewportHeight = Math.max(0, s.getViewportHeight()) + + return `${s.isSticky() ? ~bin : bin}:${viewportHeight}` +} + const upperBound = (arr: ArrayLike, target: number, length = arr.length) => { let lo = 0 let hi = length @@ -174,11 +186,9 @@ export function useVirtualHistory( }, [scrollRef]) // Quantized snapshot: same-bin scrolls (most wheel ticks) produce the same - // number → React.Object.is short-circuits the commit entirely. sticky state - // is folded in via the sign bit so sticky→broken transitions also trigger. - // Uses the TARGET (committed + pendingDelta), not committed scrollTop, so - // scrollBy notifications immediately remount for the destination before - // Ink's drain frames need the children. + // key → React.Object.is short-circuits the commit entirely. The key includes + // sticky state, target scroll position, and viewport height so resize-only + // changes still recompute the mounted transcript window. const subscribe = useCallback( (cb: () => void) => (hasScrollRef ? scrollRef.current?.subscribe(cb) : null) ?? NOOP, [hasScrollRef, scrollRef] @@ -186,19 +196,8 @@ export function useVirtualHistory( useSyncExternalStore( subscribe, - () => { - const s = scrollRef.current - - if (!s) { - return NaN - } - - const target = s.getScrollTop() + s.getPendingDelta() - const bin = Math.floor(target / QUANTUM) - - return s.isSticky() ? ~bin : bin - }, - () => NaN + () => virtualHistorySnapshotKey(scrollRef.current), + () => 'none' ) useEffect(() => { @@ -249,8 +248,26 @@ export function useVirtualHistory( // During a freeze, drop the frozen range if items shrank past its start // (/clear, compaction) — clamping would collapse to an empty mount and // flash blank. Fall through to the normal path in that case. - const frozenRange = - freezeRenders.current > 0 && prevRange.current && prevRange.current[0] < n ? prevRange.current : null + const frozenRangeCandidate = + freezeRenders.current > 0 && prevRange.current && prevRange.current[0] < n + ? ([prevRange.current[0], Math.min(prevRange.current[1], n)] as const) + : null + + // Width grows can shrink wrapped rows enough that the old tail window no + // longer covers the viewport. In that case freezing preserves stale spacers + // and visually cuts off the last message, so recompute immediately. + const frozenRange = (() => { + if (!frozenRangeCandidate || vp <= 0) { + return frozenRangeCandidate + } + + const visibleTop = sticky && !recentManual ? Math.max(0, total - vp) : target + const visibleBottom = visibleTop + vp + const rangeTop = offsets[frozenRangeCandidate[0]] ?? 0 + const rangeBottom = offsets[frozenRangeCandidate[1]] ?? total + + return rangeTop <= visibleTop && rangeBottom >= visibleBottom ? frozenRangeCandidate : null + })() let start = 0 let end = n @@ -465,6 +482,7 @@ export function useVirtualHistory( if (skipMeasurement.current) { skipMeasurement.current = false + bumpMeasuredHeightVersion(n => n + 1) } else { for (let i = effStart; i < effEnd; i++) { const k = items[i]?.key diff --git a/ui-tui/src/lib/inputMetrics.ts b/ui-tui/src/lib/inputMetrics.ts index 860b7455a37..5311e8e888b 100644 --- a/ui-tui/src/lib/inputMetrics.ts +++ b/ui-tui/src/lib/inputMetrics.ts @@ -61,6 +61,7 @@ function visualLines(value: string, cols: number): VisualLine[] { } lineStart = originalIdx + continue } @@ -178,7 +179,8 @@ export function transcriptGutterWidth(role: Role, userPrompt: string) { } export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string, termuxMode = false) { - const available = Math.max(1, totalCols - transcriptGutterWidth(role, userPrompt) - 2) + const horizontalReserve = termuxMode ? 2 : 4 + const available = Math.max(1, totalCols - transcriptGutterWidth(role, userPrompt) - horizontalReserve) if (termuxMode) { // On narrow / unusual aspect-ratio mobile panes, forcing a wide minimum