mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
chore: uptick
This commit is contained in:
parent
77cd5bf565
commit
4cbf54fb33
8 changed files with 282 additions and 239 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box borderColor={color} borderStyle="round" flexDirection="column" marginTop={1} paddingX={1}>
|
||||
<Box
|
||||
alignSelf="flex-start"
|
||||
borderColor={color}
|
||||
borderStyle="double"
|
||||
flexDirection="column"
|
||||
marginTop={1}
|
||||
opaque
|
||||
paddingX={1}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
|
|
@ -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 }) {
|
|||
</Box>
|
||||
|
||||
<NoSelect flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
|
||||
{clarify && (
|
||||
<PromptBox color={theme.color.bronze}>
|
||||
<ClarifyPrompt
|
||||
cols={cols}
|
||||
onAnswer={answerClarify}
|
||||
onCancel={() => answerClarify('')}
|
||||
req={clarify}
|
||||
t={theme}
|
||||
/>
|
||||
</PromptBox>
|
||||
)}
|
||||
|
||||
{approval && (
|
||||
<PromptBox color={theme.color.bronze}>
|
||||
<ApprovalPrompt
|
||||
onChoice={choice => {
|
||||
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}
|
||||
/>
|
||||
</PromptBox>
|
||||
)}
|
||||
|
||||
{sudo && (
|
||||
<PromptBox color={theme.color.bronze}>
|
||||
<MaskedPrompt
|
||||
cols={cols}
|
||||
icon="🔐"
|
||||
label="sudo password required"
|
||||
onSubmit={pw => {
|
||||
rpc('sudo.respond', { request_id: sudo.requestId, password: pw }).then(r => {
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
|
||||
setSudo(null)
|
||||
setStatus('running…')
|
||||
})
|
||||
}}
|
||||
t={theme}
|
||||
/>
|
||||
</PromptBox>
|
||||
)}
|
||||
|
||||
{secret && (
|
||||
<PromptBox color={theme.color.bronze}>
|
||||
<MaskedPrompt
|
||||
cols={cols}
|
||||
icon="🔑"
|
||||
label={secret.prompt}
|
||||
onSubmit={val => {
|
||||
rpc('secret.respond', { request_id: secret.requestId, value: val }).then(r => {
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
|
||||
setSecret(null)
|
||||
setStatus('running…')
|
||||
})
|
||||
}}
|
||||
sub={`for ${secret.envVar}`}
|
||||
t={theme}
|
||||
/>
|
||||
</PromptBox>
|
||||
)}
|
||||
|
||||
{picker && (
|
||||
<PromptBox color={theme.color.bronze}>
|
||||
<SessionPicker gw={gw} onCancel={() => setPicker(false)} onSelect={resumeById} t={theme} />
|
||||
</PromptBox>
|
||||
)}
|
||||
|
||||
{modelPicker && (
|
||||
<PromptBox color={theme.color.bronze}>
|
||||
<ModelPicker
|
||||
gw={gw}
|
||||
onCancel={() => setModelPicker(false)}
|
||||
onSelect={value => {
|
||||
setModelPicker(false)
|
||||
slash(`/model ${value}`)
|
||||
}}
|
||||
sessionId={sid}
|
||||
t={theme}
|
||||
/>
|
||||
</PromptBox>
|
||||
)}
|
||||
|
||||
<QueuedMessages cols={cols} queued={queuedDisplay} queueEditIdx={queueEditIdx} t={theme} />
|
||||
|
||||
{bgTasks.size > 0 && (
|
||||
|
|
@ -3356,44 +3257,177 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
<Text> </Text>
|
||||
)}
|
||||
|
||||
{statusBar && (
|
||||
<StatusRule
|
||||
bgCount={bgTasks.size}
|
||||
cols={cols}
|
||||
cwdLabel={cwdLabel}
|
||||
durationLabel={durationLabel}
|
||||
model={info?.model?.split('/').pop() ?? ''}
|
||||
status={status}
|
||||
statusColor={statusColor}
|
||||
t={theme}
|
||||
usage={usage}
|
||||
voiceLabel={voiceLabel}
|
||||
/>
|
||||
)}
|
||||
<Box flexDirection="column" position="relative">
|
||||
{statusBar && (
|
||||
<StatusRule
|
||||
bgCount={bgTasks.size}
|
||||
cols={cols}
|
||||
cwdLabel={cwdLabel}
|
||||
durationLabel={durationLabel}
|
||||
model={info?.model?.split('/').pop() ?? ''}
|
||||
status={status}
|
||||
statusColor={statusColor}
|
||||
t={theme}
|
||||
usage={usage}
|
||||
voiceLabel={voiceLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pager && (
|
||||
<Box borderColor={theme.color.bronze} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
|
||||
{pager.title && (
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text bold color={theme.color.gold}>
|
||||
{pager.title}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{(clarify || approval || sudo || secret || picker || modelPicker || pager || completions.length > 0) && (
|
||||
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
|
||||
{clarify && (
|
||||
<FloatBox color={theme.color.bronze}>
|
||||
<ClarifyPrompt
|
||||
cols={cols}
|
||||
onAnswer={answerClarify}
|
||||
onCancel={() => answerClarify('')}
|
||||
req={clarify}
|
||||
t={theme}
|
||||
/>
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => (
|
||||
<Text key={i}>{line}</Text>
|
||||
))}
|
||||
{approval && (
|
||||
<FloatBox color={theme.color.bronze}>
|
||||
<ApprovalPrompt
|
||||
onChoice={choice => {
|
||||
rpc('approval.respond', { choice, session_id: sid }).then(r => {
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.color.dim}>
|
||||
{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)`}
|
||||
</Text>
|
||||
setApproval(null)
|
||||
sys(choice === 'deny' ? 'denied' : `approved (${choice})`)
|
||||
setStatus('running…')
|
||||
})
|
||||
}}
|
||||
req={approval}
|
||||
t={theme}
|
||||
/>
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{sudo && (
|
||||
<FloatBox color={theme.color.bronze}>
|
||||
<MaskedPrompt
|
||||
cols={cols}
|
||||
icon="🔐"
|
||||
label="sudo password required"
|
||||
onSubmit={pw => {
|
||||
rpc('sudo.respond', { request_id: sudo.requestId, password: pw }).then(r => {
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
|
||||
setSudo(null)
|
||||
setStatus('running…')
|
||||
})
|
||||
}}
|
||||
t={theme}
|
||||
/>
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{secret && (
|
||||
<FloatBox color={theme.color.bronze}>
|
||||
<MaskedPrompt
|
||||
cols={cols}
|
||||
icon="🔑"
|
||||
label={secret.prompt}
|
||||
onSubmit={val => {
|
||||
rpc('secret.respond', { request_id: secret.requestId, value: val }).then(r => {
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
|
||||
setSecret(null)
|
||||
setStatus('running…')
|
||||
})
|
||||
}}
|
||||
sub={`for ${secret.envVar}`}
|
||||
t={theme}
|
||||
/>
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{picker && (
|
||||
<FloatBox color={theme.color.bronze}>
|
||||
<SessionPicker gw={gw} onCancel={() => setPicker(false)} onSelect={resumeById} t={theme} />
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{modelPicker && (
|
||||
<FloatBox color={theme.color.bronze}>
|
||||
<ModelPicker
|
||||
gw={gw}
|
||||
onCancel={() => setModelPicker(false)}
|
||||
onSelect={value => {
|
||||
setModelPicker(false)
|
||||
slash(`/model ${value}`)
|
||||
}}
|
||||
sessionId={sid}
|
||||
t={theme}
|
||||
/>
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{pager && (
|
||||
<FloatBox color={theme.color.bronze}>
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
{pager.title && (
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text bold color={theme.color.gold}>
|
||||
{pager.title}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => (
|
||||
<Text key={i}>{line}</Text>
|
||||
))}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.color.dim}>
|
||||
{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)`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{!!completions.length && (
|
||||
<FloatBox color={theme.color.bronze}>
|
||||
<Box flexDirection="column">
|
||||
{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 (
|
||||
<Box backgroundColor={bg as any} key={item.text}>
|
||||
<Text backgroundColor={bg as any} bold={active} color={fg}>
|
||||
{' '}
|
||||
{item.display}
|
||||
</Text>
|
||||
|
||||
{item.meta ? (
|
||||
<Text backgroundColor={bg as any} color={active ? fg : theme.color.dim}>
|
||||
{' '}
|
||||
{item.meta}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</FloatBox>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!isBlocked && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
|
|
@ -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 }) {
|
|||
</Box>
|
||||
)}
|
||||
|
||||
{!!completions.length && (
|
||||
<Box borderColor={theme.color.bronze} borderStyle="single" flexDirection="column" paddingX={1}>
|
||||
{completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => {
|
||||
const active = Math.max(0, compIdx - 8) + i === compIdx
|
||||
|
||||
return (
|
||||
<Text key={item.text}>
|
||||
<Text bold={active} color={active ? theme.color.amber : theme.color.cornsilk}>
|
||||
{item.display}
|
||||
</Text>
|
||||
{item.meta ? <Text color={theme.color.dim}> {item.meta}</Text> : null}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!empty && !sid && <Text color={theme.color.dim}>⚕ {status}</Text>}
|
||||
</NoSelect>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue