From 4cbf54fb332a85550f43118acc89277516c66ac7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 14 Apr 2026 19:38:04 -0500 Subject: [PATCH] chore: uptick --- ui-tui/packages/hermes-ink/src/ink/frame.ts | 2 + ui-tui/packages/hermes-ink/src/ink/ink.tsx | 19 +- ui-tui/packages/hermes-ink/src/ink/output.ts | 53 ++- .../hermes-ink/src/ink/parse-keypress.ts | 5 +- .../src/ink/render-node-to-output.ts | 7 + .../packages/hermes-ink/src/ink/renderer.ts | 2 + ui-tui/src/app.tsx | 387 +++++++++--------- ui-tui/src/hooks/useCompletion.ts | 46 ++- 8 files changed, 282 insertions(+), 239 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/frame.ts b/ui-tui/packages/hermes-ink/src/ink/frame.ts index 873b703d9..b85c0ad94 100644 --- a/ui-tui/packages/hermes-ink/src/ink/frame.ts +++ b/ui-tui/packages/hermes-ink/src/ink/frame.ts @@ -11,6 +11,8 @@ export type Frame = { readonly scrollHint?: ScrollHint | null /** A ScrollBox has remaining pendingScrollDelta — schedule another frame. */ readonly scrollDrainPending?: boolean + /** Absolute overlay moved/resized — schedule corrective frame without prevScreen. */ + readonly absoluteOverlayMoved?: boolean } export function emptyFrame( diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index ff2507ac6..7daa876ac 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -903,21 +903,12 @@ export default class Ink { // becomes frontFrame (= next frame's prevScreen). If we applied the // selection overlay, that buffer has inverted cells. selActive/hlActive // are only ever true in alt-screen; in main-screen this is false→false. - this.prevFrameContaminated = selActive || hlActive + this.prevFrameContaminated = selActive || hlActive || !!frame.absoluteOverlayMoved - // A ScrollBox has pendingScrollDelta left to drain — schedule the next - // frame. MUST NOT call this.scheduleRender() here: we're inside a - // trailing-edge throttle invocation, timerId is undefined, and lodash's - // debounce sees timeSinceLastCall >= wait (last call was at the start - // of this window) → leadingEdge fires IMMEDIATELY → double render ~0.1ms - // apart → jank. Use a plain timeout. If a wheel event arrives first, - // its scheduleRender path fires a render which clears this timer at - // the top of onRender — no double. - // - // Drain frames are cheap (DECSTBM + ~10 patches, ~200 bytes) so run at - // quarter interval (~250fps, setTimeout practical floor) for max scroll - // speed. Regular renders stay at FRAME_INTERVAL_MS via the throttle. - if (frame.scrollDrainPending) { + // Schedule corrective frame for scroll drain or absolute overlay resize. + // Plain timeout instead of scheduleRender to avoid double-render from + // lodash throttle's leadingEdge firing inside a trailing invocation. + if (frame.scrollDrainPending || frame.absoluteOverlayMoved) { this.drainTimer = setTimeout(() => this.onRender(), FRAME_INTERVAL_MS >> 2) } diff --git a/ui-tui/packages/hermes-ink/src/ink/output.ts b/ui-tui/packages/hermes-ink/src/ink/output.ts index ab417fcae..f52bf0636 100644 --- a/ui-tui/packages/hermes-ink/src/ink/output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/output.ts @@ -371,10 +371,10 @@ export default class Output { continue } - // Skip rows covered by an absolute-positioned node's clear. + // Exclude cells covered by an absolute-positioned node's clear. // Absolute nodes overlay normal-flow siblings, so prevScreen in - // that region holds the absolute node's stale paint — blitting - // it back would ghost. See absoluteClears collection above. + // that region holds stale overlay paint. If we blit those cells + // back, removed/moved overlays ghost as a duplicate. if (absoluteClears.length === 0) { blitRegion(screen, src, startX, startY, maxX, maxY) blitCells += (maxY - startY) * (maxX - startX) @@ -382,20 +382,45 @@ export default class Output { continue } - let rowStart = startY + for (let row = startY; row < maxY; row++) { + let spans: [number, number][] = [[startX, maxX]] - for (let row = startY; row <= maxY; row++) { - const excluded = - row < maxY && - absoluteClears.some(r => row >= r.y && row < r.y + r.height && startX >= r.x && maxX <= r.x + r.width) - - if (excluded || row === maxY) { - if (row > rowStart) { - blitRegion(screen, src, startX, rowStart, maxX, row) - blitCells += (row - rowStart) * (maxX - startX) + for (const r of absoluteClears) { + if (row < r.y || row >= r.y + r.height || !spans.length) { + break } - rowStart = row + 1 + const cs = Math.max(startX, r.x) + const ce = Math.min(maxX, r.x + r.width) + + if (cs >= ce) { + continue + } + + const next: [number, number][] = [] + + for (const [sx, ex] of spans) { + if (ce <= sx || cs >= ex) { + next.push([sx, ex]) + + continue + } + + if (sx < cs) { + next.push([sx, cs]) + } + + if (ce < ex) { + next.push([ce, ex]) + } + } + + spans = next + } + + for (const [sx, ex] of spans) { + blitRegion(screen, src, sx, row, ex, row + 1) + blitCells += ex - sx } } diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts index 5107f41d9..ca77058d6 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts @@ -727,10 +727,7 @@ function parseKeypress(s: string = ''): ParsedKey { return createNavKey(s, 'mouse', false) } - if (s === '\r') { - key.raw = undefined - key.name = 'return' - } else if (s === '\n') { + if (s === '\r' || s === '\n') { key.raw = undefined key.name = 'return' } else if (s === '\t') { 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 d9057725f..5c9e62b46 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 @@ -30,15 +30,21 @@ function isXtermJsHost(): boolean { // shift layout → narrow damage bounds → O(changed cells) diff instead of // O(rows×cols). let layoutShifted = false +let absoluteOverlayMoved = false export function resetLayoutShifted(): void { layoutShifted = false + absoluteOverlayMoved = false } export function didLayoutShift(): boolean { return layoutShifted } +export function didAbsoluteOverlayMove(): boolean { + return absoluteOverlayMoved +} + // DECSTBM scroll optimization hint. When a ScrollBox's scrollTop changes // between frames (and nothing else moved), log-update.ts can emit a // hardware scroll (DECSTBM + SU/SD) instead of rewriting the whole @@ -496,6 +502,7 @@ function renderNodeToOutput( if (positionChanged) { layoutShifted = true + absoluteOverlayMoved ||= node.style.position === 'absolute' } if (cached && (node.dirty || positionChanged)) { diff --git a/ui-tui/packages/hermes-ink/src/ink/renderer.ts b/ui-tui/packages/hermes-ink/src/ink/renderer.ts index ca89182d7..38e527635 100644 --- a/ui-tui/packages/hermes-ink/src/ink/renderer.ts +++ b/ui-tui/packages/hermes-ink/src/ink/renderer.ts @@ -5,6 +5,7 @@ import type { Frame } from './frame.js' import { consumeAbsoluteRemovedFlag } from './node-cache.js' import Output from './output.js' import renderNodeToOutput, { + didAbsoluteOverlayMove, getScrollDrainNode, getScrollHint, resetLayoutShifted, @@ -135,6 +136,7 @@ export default function createRenderer(node: DOMElement, stylePool: StylePool): } return { + absoluteOverlayMoved: didAbsoluteOverlayMove(), scrollHint: options.altScreen ? getScrollHint() : null, scrollDrainPending: drainNode !== null, screen: renderedScreen, diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 703f33ec2..e9687ce7c 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -288,9 +288,17 @@ function StatusRule({ // ── PromptBox ──────────────────────────────────────────────────────── -function PromptBox({ children, color }: { children: React.ReactNode; color: string }) { +function FloatBox({ children, color }: { children: React.ReactNode; color: string }) { return ( - + {children} ) @@ -559,28 +567,6 @@ export function App({ gw }: { gw: GatewayClient }) { const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked(), gw) - const applyCompletion = useCallback( - (value = input) => { - const row = completions[compIdx] - - if (!row?.text) { - return false - } - - const text = value.startsWith('/') && row.text.startsWith('/') && compReplace > 0 ? row.text.slice(1) : row.text - const next = value.slice(0, compReplace) + text - - if (next === value) { - return false - } - - setInput(next) - - return true - }, - [compIdx, compReplace, completions, input] - ) - const pulseReasoningStreaming = useCallback(() => { if (reasoningStreamingTimerRef.current) { clearTimeout(reasoningStreamingTimerRef.current) @@ -1503,7 +1489,12 @@ export function App({ gw }: { gw: GatewayClient }) { } if (key.tab && completions.length) { - applyCompletion() + const row = completions[compIdx] + + if (row?.text) { + const text = input.startsWith('/') && row.text.startsWith('/') && compReplace > 0 ? row.text.slice(1) : row.text + setInput(input.slice(0, compReplace) + text) + } return } @@ -3080,6 +3071,23 @@ export function App({ gw }: { gw: GatewayClient }) { const submit = useCallback( (value: string) => { + if (value.startsWith('/') && completions.length) { + const row = completions[compIdx] + + if (row?.text) { + const text = + value.startsWith('/') && row.text.startsWith('/') && compReplace > 0 ? row.text.slice(1) : row.text + + const next = value.slice(0, compReplace) + text + + if (next !== value) { + setInput(next) + + return + } + } + } + if (!value.trim() && !inputBuf.length) { const now = Date.now() const dbl = now - lastEmptyAt.current < 450 @@ -3137,18 +3145,7 @@ export function App({ gw }: { gw: GatewayClient }) { dispatchSubmission([...inputBuf, value].join('\n')) }, - [dequeue, dispatchSubmission, inputBuf, sid] - ) - - const submitOrComplete = useCallback( - (value: string) => { - if (value.startsWith('/') && completions.length && applyCompletion(value)) { - return - } - - submit(value) - }, - [applyCompletion, completions.length, submit] + [compIdx, compReplace, completions, dequeue, dispatchSubmission, inputBuf, sid] ) // ── Derived ────────────────────────────────────────────────────── @@ -3243,102 +3240,6 @@ export function App({ gw }: { gw: GatewayClient }) { - {clarify && ( - - answerClarify('')} - req={clarify} - t={theme} - /> - - )} - - {approval && ( - - { - rpc('approval.respond', { choice, session_id: sid }).then(r => { - if (!r) { - return - } - - setApproval(null) - sys(choice === 'deny' ? 'denied' : `approved (${choice})`) - setStatus('running…') - }) - }} - req={approval} - t={theme} - /> - - )} - - {sudo && ( - - { - rpc('sudo.respond', { request_id: sudo.requestId, password: pw }).then(r => { - if (!r) { - return - } - - setSudo(null) - setStatus('running…') - }) - }} - t={theme} - /> - - )} - - {secret && ( - - { - rpc('secret.respond', { request_id: secret.requestId, value: val }).then(r => { - if (!r) { - return - } - - setSecret(null) - setStatus('running…') - }) - }} - sub={`for ${secret.envVar}`} - t={theme} - /> - - )} - - {picker && ( - - setPicker(false)} onSelect={resumeById} t={theme} /> - - )} - - {modelPicker && ( - - setModelPicker(false)} - onSelect={value => { - setModelPicker(false) - slash(`/model ${value}`) - }} - sessionId={sid} - t={theme} - /> - - )} - {bgTasks.size > 0 && ( @@ -3356,44 +3257,177 @@ export function App({ gw }: { gw: GatewayClient }) { )} - {statusBar && ( - - )} + + {statusBar && ( + + )} - {pager && ( - - {pager.title && ( - - - {pager.title} - - - )} + {(clarify || approval || sudo || secret || picker || modelPicker || pager || completions.length > 0) && ( + + {clarify && ( + + answerClarify('')} + req={clarify} + t={theme} + /> + + )} - {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( - {line} - ))} + {approval && ( + + { + rpc('approval.respond', { choice, session_id: sid }).then(r => { + if (!r) { + return + } - - - {pager.offset + pagerPageSize < pager.lines.length - ? `Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})` - : `end · q to close (${pager.lines.length} lines)`} - + setApproval(null) + sys(choice === 'deny' ? 'denied' : `approved (${choice})`) + setStatus('running…') + }) + }} + req={approval} + t={theme} + /> + + )} + + {sudo && ( + + { + rpc('sudo.respond', { request_id: sudo.requestId, password: pw }).then(r => { + if (!r) { + return + } + + setSudo(null) + setStatus('running…') + }) + }} + t={theme} + /> + + )} + + {secret && ( + + { + rpc('secret.respond', { request_id: secret.requestId, value: val }).then(r => { + if (!r) { + return + } + + setSecret(null) + setStatus('running…') + }) + }} + sub={`for ${secret.envVar}`} + t={theme} + /> + + )} + + {picker && ( + + setPicker(false)} onSelect={resumeById} t={theme} /> + + )} + + {modelPicker && ( + + setModelPicker(false)} + onSelect={value => { + setModelPicker(false) + slash(`/model ${value}`) + }} + sessionId={sid} + t={theme} + /> + + )} + + {pager && ( + + + {pager.title && ( + + + {pager.title} + + + )} + + {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( + {line} + ))} + + + + {pager.offset + pagerPageSize < pager.lines.length + ? `Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})` + : `end · q to close (${pager.lines.length} lines)`} + + + + + )} + + {!!completions.length && ( + + + {completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => { + const active = Math.max(0, compIdx - 8) + i === compIdx + + const bg = active ? theme.color.dim : undefined + const fg = theme.color.cornsilk + + return ( + + + {' '} + {item.display} + + + {item.meta ? ( + + {' '} + {item.meta} + + ) : null} + + ) + })} + + + )} - - )} + )} + {!isBlocked && ( @@ -3418,7 +3452,7 @@ export function App({ gw }: { gw: GatewayClient }) { columns={Math.max(20, cols - 3)} onChange={setInput} onPaste={handleTextPaste} - onSubmit={submitOrComplete} + onSubmit={submit} placeholder={empty ? PLACEHOLDER : busy ? 'Ctrl+C to interrupt…' : ''} value={input} /> @@ -3426,23 +3460,6 @@ export function App({ gw }: { gw: GatewayClient }) { )} - {!!completions.length && ( - - {completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => { - const active = Math.max(0, compIdx - 8) + i === compIdx - - return ( - - - {item.display} - - {item.meta ? {item.meta} : null} - - ) - })} - - )} - {!empty && !sid && ⚕ {status}} diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index 1c74872c1..aae199324 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -1,4 +1,4 @@ -import { startTransition, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { GatewayClient } from '../gatewayClient.js' @@ -11,16 +11,20 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient const ref = useRef('') useEffect(() => { - if (blocked) { - if (completions.length) { - setCompletions([]) - setCompIdx(0) + const clear = () => { + if (!completions.length) { + return } - return + setCompletions([]) + setCompIdx(0) } - if (input === ref.current) { + if (blocked || input === ref.current) { + if (blocked) { + clear() + } + return } @@ -30,10 +34,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient const pathWord = !isSlash ? (input.match(TAB_PATH_RE)?.[1] ?? null) : null if (!isSlash && !pathWord) { - if (completions.length) { - setCompletions([]) - setCompIdx(0) - } + clear() return } @@ -53,23 +54,24 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient return } - startTransition(() => { - setCompletions(r?.items ?? []) - setCompIdx(0) - setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) - }) + setCompletions(r?.items ?? []) + setCompIdx(0) + setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) }) .catch((e: unknown) => { if (ref.current !== input) { return } - const meta = e instanceof Error && e.message ? e.message : 'unavailable' - startTransition(() => { - setCompletions([{ text: '', display: 'completion unavailable', meta }]) - setCompIdx(0) - setCompReplace(isSlash ? 1 : input.length - (pathWord?.length ?? 0)) - }) + setCompletions([ + { + text: '', + display: 'completion unavailable', + meta: e instanceof Error && e.message ? e.message : 'unavailable' + } + ]) + setCompIdx(0) + setCompReplace(isSlash ? 1 : input.length - (pathWord?.length ?? 0)) }) }, 60)