diff --git a/ui-tui/packages/hermes-ink/index.d.ts b/ui-tui/packages/hermes-ink/index.d.ts index 92aeb0a4f..637c4bb43 100644 --- a/ui-tui/packages/hermes-ink/index.d.ts +++ b/ui-tui/packages/hermes-ink/index.d.ts @@ -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' diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 6bfb0e495..355faa16f 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -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' diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx index e5a13bdb6..5851c4bef 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -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 { // 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 { // 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 { 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) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts new file mode 100644 index 000000000..58745b8c4 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts @@ -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('') + }) +}) 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 ca77058d6..56976d8a8 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts @@ -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 = '' } diff --git a/ui-tui/packages/hermes-ink/src/ink/root.ts b/ui-tui/packages/hermes-ink/src/ink/root.ts index fdfb97a6e..1d7af3803 100644 --- a/ui-tui/packages/hermes-ink/src/ink/root.ts +++ b/ui-tui/packages/hermes-ink/src/ink/root.ts @@ -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 } diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index f2326dc5a..441caf607 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -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), diff --git a/ui-tui/src/__tests__/terminalSetup.test.ts b/ui-tui/src/__tests__/terminalSetup.test.ts index 5508e94ce..59e725e98 100644 --- a/ui-tui/src/__tests__/terminalSetup.test.ts +++ b/ui-tui/src/__tests__/terminalSetup.test.ts @@ -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) diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index e3e3cea88..888bd9142 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -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) }) }) diff --git a/ui-tui/src/__tests__/viewport.test.ts b/ui-tui/src/__tests__/viewport.test.ts index d8500c8d2..eca079470 100644 --- a/ui-tui/src/__tests__/viewport.test.ts +++ b/ui-tui/src/__tests__/viewport.test.ts @@ -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('') + }) }) diff --git a/ui-tui/src/__tests__/viewportStore.test.ts b/ui-tui/src/__tests__/viewportStore.test.ts index 16031c967..7889b65cd 100644 --- a/ui-tui/src/__tests__/viewportStore.test.ts +++ b/ui-tui/src/__tests__/viewportStore.test.ts @@ -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) + }) }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 3abfc1856..801862363 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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()) diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 2cad70b9a..1b2936636 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -503,7 +503,7 @@ export const coreCommands: SlashCommand[] = [ ctx.guarded(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') diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 5acd417de..ecd1b7866 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -290,21 +290,19 @@ export const sessionCommands: SlashCommand[] = [ return ctx.transcript.sys(`usage: /indicator [${INDICATOR_STYLES.join('|')}]`) } - ctx.gateway - .rpc('config.set', { key: 'indicator', value }) - .then( - ctx.guarded(r => { - if (!r.value) { - return - } + ctx.gateway.rpc('config.set', { key: 'indicator', value }).then( + ctx.guarded(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}`) + }) + ) } }, diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 1b9d930b5..869585575 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -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' diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 84978b98c..6fdd04ea6 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -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 } diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 2c2c6d48d..df6acfadb 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -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( diff --git a/ui-tui/src/components/agentsOverlay.tsx b/ui-tui/src/components/agentsOverlay.tsx index a3a075afb..a1b349827 100644 --- a/ui-tui/src/components/agentsOverlay.tsx +++ b/ui-tui/src/components/agentsOverlay.tsx @@ -671,9 +671,7 @@ function DiffView({ {diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)} - - {diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)} - + {diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)} {diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)} diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index e85a0fb71..cf8328bc8 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -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' diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 84470c4cc..16d96f390 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -224,7 +224,12 @@ const ComposerPane = memo(function ComposerPane({ ))} - + {sh ? ( {promptLabel} diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index c5696a068..fd29d9e7e 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -133,7 +133,12 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) - + {s.title || s.preview || '(untitled)'} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 38232220a..4204ff56a 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -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 = diff --git a/ui-tui/src/domain/viewport.ts b/ui-tui/src/domain/viewport.ts index 48d7427fd..4fdbfcc93 100644 --- a/ui-tui/src/domain/viewport.ts +++ b/ui-tui/src/domain/viewport.ts @@ -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 '' diff --git a/ui-tui/src/lib/forceTruecolor.ts b/ui-tui/src/lib/forceTruecolor.ts index 78ff5bd71..3e99b6b18 100644 --- a/ui-tui/src/lib/forceTruecolor.ts +++ b/ui-tui/src/lib/forceTruecolor.ts @@ -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' } diff --git a/ui-tui/src/lib/terminalSetup.ts b/ui-tui/src/lib/terminalSetup.ts index dc00512b6..7d387797d 100644 --- a/ui-tui/src/lib/terminalSetup.ts +++ b/ui-tui/src/lib/terminalSetup.ts @@ -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)) ) diff --git a/ui-tui/src/lib/viewportStore.ts b/ui-tui/src/lib/viewportStore.ts index 0281e059b..b25ef581f 100644 --- a/ui-tui/src/lib/viewportStore.ts +++ b/ui-tui/src/lib/viewportStore.ts @@ -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, diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index e87e6eeb6..e14b8d2a5 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -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 = LIGHT_DEFAULT_TERM_PROGRAMS, + lightDefaultTermPrograms: ReadonlySet = LIGHT_DEFAULT_TERM_PROGRAMS ): boolean { const lightFlag = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase() diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 4a0bd75f1..c8038576d 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -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