mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
fix(tui): refresh virtual transcript on viewport resize (#31077)
* fix(tui): refresh virtual transcript on viewport resize Notify scroll subscribers when ScrollBox viewport bounds change and key virtual-history updates on viewport height so resize/keyboard changes remount the tail rows instead of leaving stale spacers visible. * test(tui): isolate viewport-height remount regression Keep the resize delta below the virtual history scroll quantum so the regression test specifically depends on viewport height entering the snapshot key. * test(tui): clarify virtual history resize snapshot Update the resize regression and comments so the test specifically guards viewport-height changes in the virtual-history snapshot key. * docs(tui): clarify scrollbox subscription signals Document that ScrollBox subscribers are notified for renderer-computed viewport and content bound changes, not only imperative scrolls. * fix(tui): recompute virtual tail after width resize Avoid preserving a frozen virtual transcript range when wrapped rows shrink enough that the old tail window no longer covers the viewport. * fix(tui): preserve transcript tail across resizes Wraps + heights are column-dependent, so a width change must remeasure every row and the renderer must repaint the full viewport. - Key virtualRows on cols so React remounts wrapped rows on resize. - Snap back to bottom after sticky-mode resize once React rerenders. - Reserve a scrollbar + gap column in transcriptBodyWidth (non-termux). - Full repaint on any viewport height change (was: shrink-only). - ScrollBox scrollHeight uses deepest child bottom so sticky-bottom math can reach the real final rendered row after reflow. - DECSTBM fast-path now requires full container rect match. * feat(tui): responsive banner tiers Terminals can't scale glyphs, so the banner now picks a layout per column width instead of always rendering the full 101-col logo: - Wide (>= logo width): full ASCII logo + tagline. - Mid (>= 58 cols): centered rule banner that expands with viewport. - Narrow (>= 34 cols): brand line + tagline, both width-aware. - < 34 cols: hidden. SessionPanel surfaces model/cwd/sid inline when the hero column is hidden, so narrow layouts don't lose that info. Logo width constants derive from the art itself. * fix(tui): re-check sticky inside resize debounce + document remount Addresses Copilot review on PR #31077: - onResize now re-checks isSticky() inside the 100ms timer so manual scrolls during the debounce window don't get snapped back to tail. - Comment on the virtualRows cols-keying calls out the deliberate trade-off: per-row local state (e.g. systemOpen) resets on resize so yoga can remeasure off live geometry. The hook's scale-by-ratio path is too approximate for mixed markdown widths.
This commit is contained in:
commit
f63ef74eaf
11 changed files with 367 additions and 77 deletions
|
|
@ -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
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ const stdoutOnly = (diff: ReturnType<LogUpdate['render']>) =>
|
|||
.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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<ty
|
|||
return top >= span.top && bottom <= span.bottom
|
||||
}
|
||||
|
||||
function Harness({ expose, items }: { expose: React.MutableRefObject<Exposed | null>; 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<Exposed | null>
|
||||
height?: number
|
||||
items: readonly Item[]
|
||||
maxMounted?: number
|
||||
}) {
|
||||
const scrollRef = useRef<ScrollBoxHandle | null>(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<Exposed | n
|
|||
|
||||
return React.createElement(
|
||||
ScrollBox,
|
||||
{ flexDirection: 'column', height: 10, ref: scrollRef, stickyScroll: true },
|
||||
{ flexDirection: 'column', height, ref: scrollRef, stickyScroll: true },
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: 'column', width: '100%' },
|
||||
|
|
@ -75,7 +91,11 @@ function Harness({ expose, items }: { expose: React.MutableRefObject<Exposed | n
|
|||
.map(item =>
|
||||
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<Exposed | n
|
|||
}
|
||||
|
||||
describe('useVirtualHistory offset cache reuse', () => {
|
||||
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' },
|
||||
|
|
|
|||
|
|
@ -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<TranscriptRow[]>(
|
||||
() => 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<typeof setTimeout> | 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<TerminalResizeResponse>('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid })
|
||||
}, 100)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -112,9 +112,9 @@ const TranscriptPane = memo(function TranscriptPane({
|
|||
|
||||
{row.msg.kind === 'intro' ? (
|
||||
<Box flexDirection="column" paddingTop={1}>
|
||||
<Banner t={ui.theme} />
|
||||
<Banner maxWidth={Math.max(1, composer.cols - 2)} t={ui.theme} />
|
||||
|
||||
{row.msg.info && <SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />}
|
||||
{row.msg.info && <SessionPanel info={row.msg.info} maxWidth={Math.max(1, composer.cols - 2)} sid={ui.sid} t={ui.theme} />}
|
||||
</Box>
|
||||
) : row.msg.kind === 'panel' && row.msg.panelData ? (
|
||||
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />
|
||||
|
|
|
|||
|
|
@ -29,31 +29,92 @@ function InlineLoader({ label, t }: { label: string; t: Theme }) {
|
|||
|
||||
export function ArtLines({ lines }: { lines: [string, string][] }) {
|
||||
return (
|
||||
<>
|
||||
<Box flexDirection="column" height={lines.length} opaque width={artWidth(lines)}>
|
||||
{lines.map(([c, text], i) => (
|
||||
<Text color={c} key={i}>
|
||||
<Text color={c} key={i} wrap="truncate-end">
|
||||
{text}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column" height={3} marginBottom={1} opaque width={w}>
|
||||
<Text bold color={t.color.primary}>{ruleIn(t.brand.name, w)}</Text>
|
||||
<Text color={t.color.muted}>{centerIn(TAG_FULL, w)}</Text>
|
||||
<Text color={t.color.primary}>{'─'.repeat(w)}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<ArtLines lines={logoLines} />
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{t.brand.icon} {TAG_FULL}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (cols >= COMPACT_FROM) {
|
||||
return <CompactBanner cols={cols} t={t} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? (
|
||||
<ArtLines lines={logoLines} />
|
||||
) : (
|
||||
<Text bold color={t.color.primary}>
|
||||
{t.brand.icon} NOUS HERMES
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text color={t.color.muted}>{t.brand.icon} Nous Research · Messenger of the Digital Gods</Text>
|
||||
<Text bold color={t.color.primary} wrap="truncate-end">{t.brand.icon} {name}</Text>
|
||||
<Text color={t.color.muted} wrap="truncate-end">{t.brand.icon} {tag}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -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) {
|
|||
)}
|
||||
|
||||
<Box flexDirection="column" width={w}>
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text bold color={t.color.primary}>
|
||||
{t.brand.name}
|
||||
{info.version ? ` v${info.version}` : ''}
|
||||
{info.release_date ? ` (${info.release_date})` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
{wide ? (
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text bold color={t.color.primary}>
|
||||
{t.brand.name}
|
||||
{info.version ? ` v${info.version}` : ''}
|
||||
{info.release_date ? ` (${info.release_date})` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
// Narrow layout hides the hero column; surface model/cwd/session
|
||||
// here so they aren't lost.
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={t.color.accent} wrap="truncate-end">
|
||||
{info.model.split('/').pop()}
|
||||
<Text color={t.color.muted}> · Nous Research</Text>
|
||||
</Text>
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{info.cwd || process.cwd()}
|
||||
</Text>
|
||||
{sid && (
|
||||
<Text wrap="truncate-end">
|
||||
<Text color={t.color.sessionLabel}>Session: </Text>
|
||||
<Text color={t.color.sessionBorder}>{sid}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Tools (expanded by default) ── */}
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
|
|
@ -378,6 +460,7 @@ interface PanelProps {
|
|||
|
||||
interface SessionPanelProps {
|
||||
info: SessionInfo
|
||||
maxWidth?: number
|
||||
sid?: string | null
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<number>, 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue