diff --git a/ui-tui/packages/hermes-ink/src/ink/frame.ts b/ui-tui/packages/hermes-ink/src/ink/frame.ts
index 873b703d92..b85c0ad944 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 ff2507ac65..7daa876ac3 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 ab417fcaed..f52bf06363 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 5107f41d97..ca77058d66 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 d9057725fe..5c9e62b468 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 ca89182d7e..38e5276354 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 703f33ec29..e9687ce7c3 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 1c74872c1d..aae1993240 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)