Merge pull request #17237 from NousResearch/bb/tui-paste-watchdog
fix(tui): stabilize sticky prompts and paste recovery
This commit is contained in:
2
ui-tui/packages/hermes-ink/index.d.ts
vendored
2
ui-tui/packages/hermes-ink/index.d.ts
vendored
@@ -30,7 +30,7 @@ export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts'
|
||||
export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts'
|
||||
export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts'
|
||||
export { default as measureElement } from './src/ink/measure-element.ts'
|
||||
export { createRoot, default as render, forceRedraw, renderSync } from './src/ink/root.ts'
|
||||
export { createRoot, forceRedraw, default as render, renderSync } from './src/ink/root.ts'
|
||||
export type { Instance, RenderOptions, Root } from './src/ink/root.ts'
|
||||
export { stringWidth } from './src/ink/stringWidth.ts'
|
||||
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
||||
|
||||
@@ -23,7 +23,7 @@ export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
|
||||
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
|
||||
export { default as measureElement } from './ink/measure-element.js'
|
||||
export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js'
|
||||
export { createRoot, default as render, forceRedraw, renderSync } from './ink/root.js'
|
||||
export { createRoot, forceRedraw, default as render, renderSync } from './ink/root.js'
|
||||
export { stringWidth } from './ink/stringWidth.js'
|
||||
export { isXtermJs } from './ink/terminal.js'
|
||||
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { PureComponent, type ReactNode } from 'react'
|
||||
import { PureComponent, type ReactNode } from 'react'
|
||||
|
||||
import { updateLastInteractionTime } from '../../bootstrap/state.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
@@ -316,8 +316,10 @@ export default class App extends PureComponent<Props, State> {
|
||||
// Clear the timer reference
|
||||
this.incompleteEscapeTimer = null
|
||||
|
||||
// Only proceed if we have incomplete sequences
|
||||
if (!this.keyParseState.incomplete) {
|
||||
// Only proceed if we have an incomplete escape sequence or an unterminated
|
||||
// bracketed paste. Missing paste-end markers otherwise leave every later
|
||||
// keystroke trapped in the paste buffer.
|
||||
if (!this.keyParseState.incomplete && this.keyParseState.mode !== 'IN_PASTE') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -330,13 +332,16 @@ export default class App extends PureComponent<Props, State> {
|
||||
// drain stdin next and clear this timer. Prevents both the spurious
|
||||
// Escape key and the lost scroll event.
|
||||
if (this.props.stdin.readableLength > 0) {
|
||||
this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT)
|
||||
this.incompleteEscapeTimer = setTimeout(
|
||||
this.flushIncomplete,
|
||||
this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Process incomplete as a flush operation (input=null)
|
||||
// This reuses all existing parsing logic
|
||||
// Process incomplete/paste state as a flush operation (input=null).
|
||||
// This reuses all existing parsing logic.
|
||||
this.processInput(null)
|
||||
}
|
||||
|
||||
@@ -355,8 +360,10 @@ export default class App extends PureComponent<Props, State> {
|
||||
reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined)
|
||||
}
|
||||
|
||||
// If we have incomplete escape sequences, set a timer to flush them
|
||||
if (this.keyParseState.incomplete) {
|
||||
// If we have incomplete escape sequences or an unterminated paste, set a
|
||||
// timer to flush/reset them. Paste starts are complete CSI sequences, so
|
||||
// checking only `incomplete` would never arm the watchdog.
|
||||
if (this.keyParseState.incomplete || this.keyParseState.mode === 'IN_PASTE') {
|
||||
// Cancel any existing timer first
|
||||
if (this.incompleteEscapeTimer) {
|
||||
clearTimeout(this.incompleteEscapeTimer)
|
||||
|
||||
41
ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts
Normal file
41
ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { INITIAL_STATE, parseMultipleKeypresses } from './parse-keypress.js'
|
||||
import { PASTE_END, PASTE_START } from './termio/csi.js'
|
||||
|
||||
describe('parseMultipleKeypresses bracketed paste recovery', () => {
|
||||
it('emits empty bracketed pastes when the terminal sends both markers', () => {
|
||||
const [keys, state] = parseMultipleKeypresses(INITIAL_STATE, PASTE_START + PASTE_END)
|
||||
|
||||
expect(keys).toHaveLength(1)
|
||||
expect(keys[0]).toMatchObject({ isPasted: true, raw: '' })
|
||||
expect(state.mode).toBe('NORMAL')
|
||||
})
|
||||
|
||||
it('flushes unterminated paste content back to normal input mode', () => {
|
||||
const [pendingKeys, pendingState] = parseMultipleKeypresses(INITIAL_STATE, PASTE_START + 'hello')
|
||||
|
||||
expect(pendingKeys).toEqual([])
|
||||
expect(pendingState.mode).toBe('IN_PASTE')
|
||||
|
||||
const [keys, state] = parseMultipleKeypresses(pendingState, null)
|
||||
|
||||
expect(keys).toHaveLength(1)
|
||||
expect(keys[0]).toMatchObject({ isPasted: true, raw: 'hello' })
|
||||
expect(state.mode).toBe('NORMAL')
|
||||
expect(state.pasteBuffer).toBe('')
|
||||
})
|
||||
|
||||
it('resets an empty unterminated paste start instead of staying stuck', () => {
|
||||
const [pendingKeys, pendingState] = parseMultipleKeypresses(INITIAL_STATE, PASTE_START)
|
||||
|
||||
expect(pendingKeys).toEqual([])
|
||||
expect(pendingState.mode).toBe('IN_PASTE')
|
||||
|
||||
const [keys, state] = parseMultipleKeypresses(pendingState, null)
|
||||
|
||||
expect(keys).toEqual([])
|
||||
expect(state.mode).toBe('NORMAL')
|
||||
expect(state.pasteBuffer).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -288,9 +288,14 @@ export function parseMultipleKeypresses(
|
||||
}
|
||||
}
|
||||
|
||||
// If flushing and still in paste mode, emit what we have
|
||||
if (isFlush && inPaste && pasteBuffer) {
|
||||
keys.push(createPasteKey(pasteBuffer))
|
||||
// If a terminal drops the paste-end marker, the App watchdog flushes the
|
||||
// partial paste and returns to normal input instead of swallowing all future
|
||||
// keystrokes as paste content.
|
||||
if (isFlush && inPaste) {
|
||||
if (pasteBuffer) {
|
||||
keys.push(createPasteKey(pasteBuffer))
|
||||
}
|
||||
|
||||
inPaste = false
|
||||
pasteBuffer = ''
|
||||
}
|
||||
|
||||
@@ -75,11 +75,13 @@ export type Root = {
|
||||
|
||||
export const forceRedraw = (stdout: NodeJS.WriteStream = process.stdout): boolean => {
|
||||
const instance = instances.get(stdout)
|
||||
|
||||
if (!instance) {
|
||||
return false
|
||||
}
|
||||
|
||||
instance.forceRedraw()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -714,9 +714,7 @@ describe('createGatewayEventHandler', () => {
|
||||
} as any)
|
||||
|
||||
// Pre-interrupt todos should land in turn state.
|
||||
expect(getTurnState().todos).toEqual([
|
||||
{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }
|
||||
])
|
||||
expect(getTurnState().todos).toEqual([{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }])
|
||||
|
||||
turnController.interruptTurn({
|
||||
appendMessage: (msg: Msg) => appended.push(msg),
|
||||
|
||||
@@ -141,6 +141,7 @@ describe('configureTerminalKeybindings', () => {
|
||||
// it overlaps any context, including our terminal scope. We must NOT
|
||||
// silently add a terminal-scoped cmd+c that would shadow it.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
@@ -149,6 +150,7 @@ describe('configureTerminalKeybindings', () => {
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
@@ -170,6 +172,7 @@ describe('configureTerminalKeybindings', () => {
|
||||
// would shadow ours. Treat as a conflict even though the strings
|
||||
// aren't identical.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
@@ -179,6 +182,7 @@ describe('configureTerminalKeybindings', () => {
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
@@ -198,6 +202,7 @@ describe('configureTerminalKeybindings', () => {
|
||||
// logically disjoint from our copy-forwarding binding, which requires
|
||||
// terminalTextSelected.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
@@ -208,6 +213,7 @@ describe('configureTerminalKeybindings', () => {
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
@@ -226,6 +232,7 @@ describe('configureTerminalKeybindings', () => {
|
||||
// clauses don't overlap. A user's pre-existing cmd+c binding scoped to
|
||||
// editor focus should NOT block our terminal-scoped cmd+c binding.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
@@ -235,6 +242,7 @@ describe('configureTerminalKeybindings', () => {
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
|
||||
@@ -16,14 +16,16 @@ const RELEVANT_ENV = [
|
||||
'HERMES_TUI_THEME',
|
||||
'HERMES_TUI_BACKGROUND',
|
||||
'COLORFGBG',
|
||||
'TERM_PROGRAM',
|
||||
'TERM_PROGRAM'
|
||||
] as const
|
||||
|
||||
async function importThemeWithCleanEnv() {
|
||||
for (const key of RELEVANT_ENV) {
|
||||
vi.stubEnv(key, '')
|
||||
}
|
||||
|
||||
vi.resetModules()
|
||||
|
||||
return import('../theme.js')
|
||||
}
|
||||
|
||||
@@ -165,9 +167,7 @@ describe('detectLightMode', () => {
|
||||
expect(detectLightMode({ TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true)
|
||||
|
||||
// Dark COLORFGBG must beat the allow-list.
|
||||
expect(
|
||||
detectLightMode({ COLORFGBG: '15;0', TERM_PROGRAM: 'Apple_Terminal' }, allowList),
|
||||
).toBe(false)
|
||||
expect(detectLightMode({ COLORFGBG: '15;0', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -28,4 +28,31 @@ describe('stickyPromptFromViewport', () => {
|
||||
|
||||
expect(stickyPromptFromViewport(messages, offsets, 16, 20, false)).toBe('current prompt')
|
||||
})
|
||||
|
||||
it('shows the last prompt once the viewport starts after the history tail', () => {
|
||||
const messages = [
|
||||
{ role: 'user' as const, text: 'current prompt' },
|
||||
{ role: 'assistant' as const, text: 'completed answer' }
|
||||
]
|
||||
|
||||
expect(stickyPromptFromViewport(messages, [0, 2, 5], 8, 14, false)).toBe('current prompt')
|
||||
})
|
||||
|
||||
it('shows a prompt as soon as its full row is above the viewport', () => {
|
||||
const messages = [
|
||||
{ role: 'user' as const, text: 'current prompt' },
|
||||
{ role: 'assistant' as const, text: 'current answer' }
|
||||
]
|
||||
|
||||
expect(stickyPromptFromViewport(messages, [0, 2, 10], 2, 8, false)).toBe('current prompt')
|
||||
})
|
||||
|
||||
it('hides the sticky prompt at the bottom', () => {
|
||||
const messages = [
|
||||
{ role: 'user' as const, text: 'current prompt' },
|
||||
{ role: 'assistant' as const, text: 'current answer' }
|
||||
]
|
||||
|
||||
expect(stickyPromptFromViewport(messages, [0, 2, 10], 8, 10, true)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,4 +35,20 @@ describe('viewportStore', () => {
|
||||
})
|
||||
expect(viewportSnapshotKey(snap)).toBe('0:16:5:40:3')
|
||||
})
|
||||
|
||||
it('uses fresh scroll height to clear stale non-bottom state', () => {
|
||||
const handle = {
|
||||
getFreshScrollHeight: () => 20,
|
||||
getPendingDelta: () => 0,
|
||||
getScrollHeight: () => 40,
|
||||
getScrollTop: () => 15,
|
||||
getViewportHeight: () => 5,
|
||||
isSticky: () => false
|
||||
}
|
||||
|
||||
const snap = getViewportSnapshot(handle as any)
|
||||
|
||||
expect(snap.atBottom).toBe(true)
|
||||
expect(snap.scrollHeight).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -373,6 +373,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||
// 120-char clip used for `gateway.stderr` activity entries.
|
||||
const STDERR_LINE_CAP = 120
|
||||
const STDERR_LINES_MAX = 8
|
||||
|
||||
const tailLines = (stderrTail ?? '')
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
|
||||
@@ -503,7 +503,7 @@ export const coreCommands: SlashCommand[] = [
|
||||
ctx.guarded<SessionSteerResponse>(r => {
|
||||
if (r?.status === 'queued') {
|
||||
ctx.transcript.sys(
|
||||
`⏩ steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`
|
||||
`steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`
|
||||
)
|
||||
} else {
|
||||
ctx.transcript.sys('steer rejected')
|
||||
|
||||
@@ -290,21 +290,19 @@ export const sessionCommands: SlashCommand[] = [
|
||||
return ctx.transcript.sys(`usage: /indicator [${INDICATOR_STYLES.join('|')}]`)
|
||||
}
|
||||
|
||||
ctx.gateway
|
||||
.rpc<ConfigSetResponse>('config.set', { key: 'indicator', value })
|
||||
.then(
|
||||
ctx.guarded<ConfigSetResponse>(r => {
|
||||
if (!r.value) {
|
||||
return
|
||||
}
|
||||
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'indicator', value }).then(
|
||||
ctx.guarded<ConfigSetResponse>(r => {
|
||||
if (!r.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Hot-swap the running TUI immediately so the next render
|
||||
// uses the new style without waiting for the 5s mtime poll
|
||||
// to re-apply config.full.
|
||||
patchUiState({ indicatorStyle: value as IndicatorStyle })
|
||||
ctx.transcript.sys(`indicator → ${r.value}`)
|
||||
})
|
||||
)
|
||||
// Hot-swap the running TUI immediately so the next render
|
||||
// uses the new style without waiting for the 5s mtime poll
|
||||
// to re-apply config.full.
|
||||
patchUiState({ indicatorStyle: value as IndicatorStyle })
|
||||
ctx.transcript.sys(`indicator → ${r.value}`)
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ import type {
|
||||
import { asRpcResult } from '../lib/rpc.js'
|
||||
|
||||
import {
|
||||
type BusyInputMode,
|
||||
DEFAULT_INDICATOR_STYLE,
|
||||
INDICATOR_STYLES,
|
||||
type BusyInputMode,
|
||||
type IndicatorStyle,
|
||||
type StatusBarMode,
|
||||
type StatusBarMode
|
||||
} from './interfaces.js'
|
||||
import { turnController } from './turnController.js'
|
||||
import { patchUiState } from './uiStore.js'
|
||||
|
||||
@@ -366,6 +366,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
|
||||
if (isCtrl(key, ch, 'x') && cState.queueEditIdx !== null) {
|
||||
cActions.removeQueue(cState.queueEditIdx)
|
||||
|
||||
return cActions.clearIn()
|
||||
}
|
||||
|
||||
@@ -393,6 +394,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
if (isAction(key, ch, 'l')) {
|
||||
clearSelection()
|
||||
forceRedraw(terminal.stdout ?? process.stdout)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -227,6 +227,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
(full: string, opts: { fallbackToFront?: boolean } = {}) => {
|
||||
const live = getUiState()
|
||||
const mode = live.busyInputMode
|
||||
|
||||
const fallback = (note: string) => {
|
||||
if (opts.fallbackToFront) {
|
||||
composerRefs.queueRef.current.unshift(full)
|
||||
@@ -234,6 +235,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
} else {
|
||||
composerActions.enqueue(full)
|
||||
}
|
||||
|
||||
sys(note)
|
||||
}
|
||||
|
||||
@@ -350,17 +352,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
|
||||
send(full)
|
||||
},
|
||||
[
|
||||
appendMessage,
|
||||
composerActions,
|
||||
composerRefs,
|
||||
handleBusyInput,
|
||||
interpolate,
|
||||
send,
|
||||
sendQueued,
|
||||
shellExec,
|
||||
slashRef
|
||||
]
|
||||
[appendMessage, composerActions, composerRefs, handleBusyInput, interpolate, send, sendQueued, shellExec, slashRef]
|
||||
)
|
||||
|
||||
const submit = useCallback(
|
||||
|
||||
@@ -671,9 +671,7 @@ function DiffView({
|
||||
<Text color={t.color.text}>
|
||||
{diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)}
|
||||
</Text>
|
||||
<Text color={t.color.text}>
|
||||
{diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)}
|
||||
</Text>
|
||||
<Text color={t.color.text}>{diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)}</Text>
|
||||
<Text color={t.color.text}>{diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -5,8 +5,8 @@ import unicodeSpinners from 'unicode-animations'
|
||||
|
||||
import { $delegationState } from '../app/delegationStore.js'
|
||||
import type { IndicatorStyle } from '../app/interfaces.js'
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import { useTurnSelector } from '../app/turnStore.js'
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import { FACES } from '../content/faces.js'
|
||||
import { VERBS } from '../content/verbs.js'
|
||||
import { fmtDuration } from '../domain/messages.js'
|
||||
|
||||
@@ -224,7 +224,12 @@ const ComposerPane = memo(function ComposerPane({
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Box onMouseDown={captureInputDrag} onMouseDrag={dragFromPromptRow} onMouseUp={endInputDrag} position="relative">
|
||||
<Box
|
||||
onMouseDown={captureInputDrag}
|
||||
onMouseDrag={dragFromPromptRow}
|
||||
onMouseUp={endInputDrag}
|
||||
position="relative"
|
||||
>
|
||||
<Box width={promptWidth}>
|
||||
{sh ? (
|
||||
<Text color={ui.theme.color.shellDollar}>{promptLabel}</Text>
|
||||
|
||||
@@ -133,7 +133,12 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected} wrap="truncate-end">
|
||||
<Text
|
||||
bold={selected}
|
||||
color={selected ? t.color.accent : t.color.muted}
|
||||
inverse={selected}
|
||||
wrap="truncate-end"
|
||||
>
|
||||
{s.title || s.preview || '(untitled)'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -873,7 +873,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
const hasTools = groups.length > 0
|
||||
const hasSubagents = subagents.length > 0
|
||||
const hasMeta = meta.length > 0
|
||||
const hasThinking = !!cot || reasoningActive || busy
|
||||
const hasThinking = !!cot || reasoningActive || reasoningStreaming
|
||||
const thinkingLive = reasoningActive || reasoningStreaming
|
||||
|
||||
const tokenCount =
|
||||
|
||||
@@ -26,21 +26,25 @@ export const stickyPromptFromViewport = (
|
||||
return ''
|
||||
}
|
||||
|
||||
const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1))
|
||||
const last = Math.max(first, Math.min(messages.length - 1, upperBound(offsets, bottom) - 1))
|
||||
const first = Math.max(0, upperBound(offsets, top) - 1)
|
||||
const last = Math.max(first, upperBound(offsets, bottom) - 1)
|
||||
const visibleStart = Math.min(messages.length, first)
|
||||
const visibleEnd = Math.min(messages.length - 1, last)
|
||||
|
||||
for (let i = first; i <= last; i++) {
|
||||
for (let i = visibleStart; i <= visibleEnd; i++) {
|
||||
if (messages[i]?.role === 'user') {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = first - 1; i >= 0; i--) {
|
||||
for (let i = Math.min(messages.length - 1, visibleStart - 1); i >= 0; i--) {
|
||||
if (messages[i]?.role !== 'user') {
|
||||
continue
|
||||
}
|
||||
|
||||
return (offsets[i] ?? 0) + 1 < top ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() : ''
|
||||
return (offsets[i + 1] ?? (offsets[i] ?? 0) + 1) <= top
|
||||
? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim()
|
||||
: ''
|
||||
}
|
||||
|
||||
return ''
|
||||
|
||||
@@ -21,14 +21,11 @@
|
||||
* no effect.
|
||||
*/
|
||||
|
||||
if (
|
||||
process.env.HERMES_TUI_TRUECOLOR !== '0' &&
|
||||
!process.env.NO_COLOR &&
|
||||
!process.env.FORCE_COLOR
|
||||
) {
|
||||
if (process.env.HERMES_TUI_TRUECOLOR !== '0' && !process.env.NO_COLOR && !process.env.FORCE_COLOR) {
|
||||
if (!process.env.COLORTERM) {
|
||||
process.env.COLORTERM = 'truecolor'
|
||||
}
|
||||
|
||||
process.env.FORCE_COLOR = '3'
|
||||
}
|
||||
|
||||
|
||||
@@ -336,6 +336,7 @@ export async function configureTerminalKeybindings(
|
||||
}
|
||||
|
||||
const targets = targetBindings(platform)
|
||||
|
||||
const conflicts = targets.filter(target =>
|
||||
keybindings.some(existing => isKeybinding(existing) && bindingsConflict(existing, target))
|
||||
)
|
||||
|
||||
@@ -28,11 +28,18 @@ export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapsho
|
||||
const pending = s.getPendingDelta()
|
||||
const top = Math.max(0, s.getScrollTop() + pending)
|
||||
const viewportHeight = Math.max(0, s.getViewportHeight())
|
||||
const scrollHeight = Math.max(viewportHeight, s.getScrollHeight())
|
||||
const cachedScrollHeight = Math.max(viewportHeight, s.getScrollHeight())
|
||||
let scrollHeight = cachedScrollHeight
|
||||
const bottom = top + viewportHeight
|
||||
let atBottom = s.isSticky() || bottom >= scrollHeight - 2
|
||||
|
||||
if (!atBottom) {
|
||||
scrollHeight = Math.max(viewportHeight, s.getFreshScrollHeight?.() ?? cachedScrollHeight)
|
||||
atBottom = s.isSticky() || bottom >= scrollHeight - 2
|
||||
}
|
||||
|
||||
return {
|
||||
atBottom: s.isSticky() || bottom >= scrollHeight - 2,
|
||||
atBottom,
|
||||
bottom,
|
||||
pending,
|
||||
scrollHeight,
|
||||
|
||||
@@ -219,6 +219,7 @@ function backgroundLuminance(raw: string): null | number {
|
||||
}
|
||||
|
||||
const hex = v.startsWith('#') ? v.slice(1) : v
|
||||
|
||||
const rgb = HEX_6_RE.test(hex)
|
||||
? [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
|
||||
: HEX_3_RE.test(hex)
|
||||
@@ -254,7 +255,7 @@ export function detectLightMode(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
// Injectable so tests can prove the COLORFGBG-over-TERM_PROGRAM
|
||||
// precedence rule even though the production allow-list is empty.
|
||||
lightDefaultTermPrograms: ReadonlySet<string> = LIGHT_DEFAULT_TERM_PROGRAMS,
|
||||
lightDefaultTermPrograms: ReadonlySet<string> = LIGHT_DEFAULT_TERM_PROGRAMS
|
||||
): boolean {
|
||||
const lightFlag = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase()
|
||||
|
||||
|
||||
1
ui-tui/src/types/hermes-ink.d.ts
vendored
1
ui-tui/src/types/hermes-ink.d.ts
vendored
@@ -83,6 +83,7 @@ declare module '@hermes/ink' {
|
||||
readonly getScrollTop: () => number
|
||||
readonly getPendingDelta: () => number
|
||||
readonly getScrollHeight: () => number
|
||||
readonly getFreshScrollHeight: () => number
|
||||
readonly getViewportHeight: () => number
|
||||
readonly getViewportTop: () => number
|
||||
readonly getLastManualScrollAt: () => number
|
||||
|
||||
Reference in New Issue
Block a user