* feat(tui): make display.mouse_tracking pick which DEC modes to enable Previously the boolean flag was all-or-nothing across modes 1000+1002+1003+1006. Inside tmux, mode 1003 (any-motion) makes every mouse cross of the prompt row fire a clipboard probe that surfaces as "No image in clipboard" — sometimes dozens in a row. Disabling tracking entirely killed scroll-wheel scrolling too, since tmux's own scrollback is preempted by the alt-screen TUI. `display.mouse_tracking` (and `/mouse <preset>`) now accepts `off | wheel | buttons | all` in addition to the legacy booleans. `wheel` is 1000+1006: scroll wheel + click only, no drag, no hover — the tmux-friendly subset. `buttons` adds 1002 for drag-to-select. `all` (= legacy `true`) keeps the hover-driven UI (scrollbar paginate-on-hover, link mouseenter, etc.). * fix(tui): repaint + sync mouse mode when display.mouse_tracking changes Two interacting bugs left the TUI blank when `display.mouse_tracking` switched at runtime (config edit, /mouse <preset>): 1. AlternateScreen's effect re-runs on every `mouseTracking` change, tearing down and re-entering the alt screen. After re-entry, ink's frame buffers are reset by `resetFramesForAltScreen()` but nothing schedules the follow-up render — the alt screen sits blank until some other state change happens to trigger one. Add a `scheduleRender()` in `setAltScreenActive`'s active=true branch so the freshly-entered alt screen gets a full repaint immediately. 2. `setAltScreenActive` early-returns when `active` hasn't changed, which silently drops a `mouseTracking` change if the cleanup→setup pair somehow leaves `altScreenActive` already true. Call `setAltScreenMouseTracking` explicitly from the AlternateScreen effect so the in-memory mode and terminal DECSET sequence stay in sync regardless of how `setAltScreenActive` resolved (the call is a no-op when the mode is unchanged). * fix(tui): address copilot review #4341269705 - tui_gateway/server.py: drop the never-referenced _MOUSE_TRACKING_MODES frozenset (comment #3284802434). _MOUSE_TRACKING_ALIASES already centralizes the canonical preset set via its values; the separate constant added no behavior. - tests/test_tui_gateway_server.py: update the existing test_config_mouse_uses_documented_key_with_legacy_fallback to assert the new preset strings ('all'/'off' instead of 'on'/'off', display.mouse_tracking persisted as 'all' instead of True) and add test_config_mouse_accepts_preset_strings_and_aliases covering /mouse set with wheel/click/unknown (comment #3284802453). The on/off legacy config.set return shape was an implementation detail of the boolean flag, not a stable API — the slash command, gateway help text, and docs all advertise the preset values now. - ui-tui/packages/hermes-ink/src/ink/ink.tsx: schedule a render at the end of reenterAltScreen() (comment #3284802461). Mirrors the same fix in setAltScreenActive() from ece0a2f4c — without it, SIGCONT/resize self-heal/stdin-gap re-entry leaves the alt screen blank because every caller returns early after invoking us. * fix(tui): address copilot review #4341308478 round 2 - ui-tui/src/config/env.ts (comment #3284837577): the precedence comment was misleading. Actual behavior on origin/main is HERMES_TUI_MOUSE_TRACKING (explicit override) > Termux default > HERMES_TUI_DISABLE_MOUSE legacy kill-switch. This is preserved from main; the only change here was the wrong comment that claimed DISABLE_MOUSE kept kill-switch semantics. Rewrote the comment block to document the actual precedence ladder. - tui_gateway/server.py /mouse set (comment #3284837607): replaced 'str(value or "").strip().lower()' with the explicit None idiom already used for /indicator, so programmatic callers can pass 0 / False and have them route through _MOUSE_TRACKING_ALIASES → 'off' instead of collapsing to '' and triggering the toggle path. - ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx (comment #3284837620): always prepend DISABLE_MOUSE_TRACKING before enableMouseTrackingFor(...) on mount. Otherwise selecting 'wheel'/'buttons' from a state where DEC 1003 was already asserted (crash, another app, debugger) would silently leave hover on. Also unconditionally DISABLE on unmount so a crash mid-mount can't leak DEC modes back to the host shell. * chore(release): map nat@nthrow.io to @nthrow for #26681 salvage * fix(tui): drop redundant setAltScreenMouseTracking in AlternateScreen Copilot review #4341356637 (comment #3284880417). The explicit setAltScreenMouseTracking(mouseTracking) after setAltScreenActive(true, mouseTracking) was defensive paranoia added in the previous fix commit that's not actually reachable in practice: - React's cleanup always runs before the next setup, so on any prop change (mouseTracking or writeRaw) the cleanup sets active=false first. Setup then sees active was false and applies the new mode via setAltScreenActive without early-returning. - On the impossible 'active stayed true' path, the writeRaw above has already sent DISABLE_MOUSE_TRACKING + enableMouseTrackingFor(newMode) to the terminal, so the in-memory mode would lag but the visible state is already correct. Removing the redundant call means a single DEC sequence per mount. If the 'active stayed true' path ever manifests in practice, the right fix is in setAltScreenActive (track mode regardless of the active early-return), not here. * fix(tui): always DISABLE before enableMouseTrackingFor in ink.tsx Copilot review #4341379994 (comments #3284900825, #3284900840, #3284900852). Three remaining call sites in ink.tsx still re-enabled mouse tracking without first sending DISABLE_MOUSE_TRACKING: - handleResize alt-screen recovery (line ~577) - reassertTerminalModes stdin-gap re-assertion (line ~1351) - reenterAltScreen SIGCONT/resize/stdin-gap self-heal (line ~1408) For 'wheel'/'buttons' presets, omitting DISABLE leaves any externally- asserted DEC 1003 (other apps, prior crash, tmux state) still active and the hover-free preset silently has hover on. DISABLE_MOUSE_TRACKING is idempotent and safe to send unconditionally — it resets all four modes. Matches the pattern already in setAltScreenMouseTracking and the AlternateScreen mount path. * fix(tui): always DISABLE before enableMouseTrackingFor in exitAlternateScreen Copilot review #4341452823 (comment #3284959762). exitAlternateScreen() was the last call site in ink.tsx still re-enabling mouse tracking without DISABLE first. Editors (vim/nvim/less) and tmux can leave DEC 1003 hover asserted across the handoff back; without DISABLE, 'wheel'/'buttons' presets silently kept hover on after the editor quit. Now all five enableMouseTrackingFor() call sites in ink.tsx prepend DISABLE_MOUSE_TRACKING — handleResize, reassertTerminalModes, reenterAltScreen, setAltScreenMouseTracking, exitAlternateScreen. * fix(tui): add defensive default to enableMouseTrackingFor switch Copilot review #4341485231 (comment #3284979323). TS exhaustive switch returns string per the type system, but a JS caller / corrupted config / hot-reload-in-dev could reach the function with an unknown value at runtime. Without a default, that path returns undefined which then concatenates as the literal string 'undefined' into the terminal byte stream — visibly garbling output. Treat unknown as 'off' (no DEC sequences) so the worst case is silent input loss rather than a wrecked screen. --------- Co-authored-by: Nat Thrower <nat@nthrow.io>
377 lines
9.9 KiB
TypeScript
377 lines
9.9 KiB
TypeScript
import type { MouseTrackingMode, ScrollBoxHandle } from '@hermes/ink'
|
|
import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'react'
|
|
|
|
import type { PasteEvent } from '../components/textInput.js'
|
|
import type { GatewayClient } from '../gatewayClient.js'
|
|
import type { ImageAttachResponse } from '../gatewayTypes.js'
|
|
import type { ParsedVoiceRecordKey } from '../lib/platform.js'
|
|
import type { RpcResult } from '../lib/rpc.js'
|
|
import type { Theme } from '../theme.js'
|
|
import type {
|
|
ApprovalReq,
|
|
ClarifyReq,
|
|
ConfirmReq,
|
|
DetailsMode,
|
|
Msg,
|
|
PanelSection,
|
|
SecretReq,
|
|
SectionVisibility,
|
|
SessionInfo,
|
|
SlashCatalog,
|
|
SudoReq,
|
|
Usage
|
|
} from '../types.js'
|
|
|
|
export interface StateSetter<T> {
|
|
(value: SetStateAction<T>): void
|
|
}
|
|
|
|
export type StatusBarMode = 'bottom' | 'off' | 'top'
|
|
|
|
export type BusyInputMode = 'interrupt' | 'queue' | 'steer'
|
|
|
|
// Single source of truth for indicator style names. Union type is
|
|
// derived from this tuple so adding/removing a style only touches one
|
|
// line — `useConfigSync` (validation) and `session.ts` (slash arg
|
|
// validation + usage hint) both import it.
|
|
export const INDICATOR_STYLES = ['ascii', 'emoji', 'kaomoji', 'unicode'] as const
|
|
export type IndicatorStyle = (typeof INDICATOR_STYLES)[number]
|
|
export const DEFAULT_INDICATOR_STYLE: IndicatorStyle = 'kaomoji'
|
|
|
|
export interface SelectionApi {
|
|
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
|
clearSelection: () => void
|
|
copySelection: () => Promise<string>
|
|
copySelectionNoClear: () => Promise<string>
|
|
getState: () => unknown
|
|
version: () => number
|
|
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
|
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
|
}
|
|
|
|
export interface CompletionItem {
|
|
display: string
|
|
meta?: string
|
|
text: string
|
|
}
|
|
|
|
export interface GatewayRpc {
|
|
<T extends RpcResult = RpcResult>(method: string, params?: Record<string, unknown>): Promise<null | T>
|
|
}
|
|
|
|
export interface GatewayServices {
|
|
gw: GatewayClient
|
|
rpc: GatewayRpc
|
|
}
|
|
|
|
export interface GatewayProviderProps {
|
|
children: ReactNode
|
|
value: GatewayServices
|
|
}
|
|
|
|
export interface OverlayState {
|
|
agents: boolean
|
|
agentsInitialHistoryIndex: number
|
|
approval: ApprovalReq | null
|
|
clarify: ClarifyReq | null
|
|
confirm: ConfirmReq | null
|
|
modelPicker: boolean
|
|
pager: null | PagerState
|
|
picker: boolean
|
|
secret: null | SecretReq
|
|
skillsHub: boolean
|
|
sudo: null | SudoReq
|
|
}
|
|
|
|
export interface PagerState {
|
|
lines: string[]
|
|
offset: number
|
|
title?: string
|
|
}
|
|
|
|
export interface TranscriptRow {
|
|
index: number
|
|
key: string
|
|
msg: Msg
|
|
}
|
|
|
|
export interface UiState {
|
|
bgTasks: Set<string>
|
|
busy: boolean
|
|
busyInputMode: BusyInputMode
|
|
compact: boolean
|
|
detailsMode: DetailsMode
|
|
detailsModeCommandOverride: boolean
|
|
info: null | SessionInfo
|
|
inlineDiffs: boolean
|
|
mouseTracking: MouseTrackingMode
|
|
sections: SectionVisibility
|
|
showCost: boolean
|
|
showReasoning: boolean
|
|
indicatorStyle: IndicatorStyle
|
|
sid: null | string
|
|
status: string
|
|
statusBar: StatusBarMode
|
|
streaming: boolean
|
|
theme: Theme
|
|
usage: Usage
|
|
}
|
|
|
|
export interface VirtualHistoryState {
|
|
bottomSpacer: number
|
|
end: number
|
|
measureRef: (key: string) => (el: unknown) => void
|
|
offsets: ArrayLike<number>
|
|
start: number
|
|
topSpacer: number
|
|
}
|
|
|
|
export interface ComposerPasteResult {
|
|
cursor: number
|
|
value: string
|
|
}
|
|
|
|
export type MaybePromise<T> = Promise<T> | T
|
|
|
|
export interface ComposerActions {
|
|
clearIn: () => void
|
|
dequeue: () => string | undefined
|
|
enqueue: (text: string) => void
|
|
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
|
|
openEditor: () => Promise<void>
|
|
pushHistory: (text: string) => void
|
|
removeQueue: (index: number) => void
|
|
replaceQueue: (index: number, text: string) => void
|
|
setCompIdx: StateSetter<number>
|
|
setHistoryIdx: StateSetter<null | number>
|
|
setInput: StateSetter<string>
|
|
setInputBuf: StateSetter<string[]>
|
|
setPasteSnips: StateSetter<PasteSnippet[]>
|
|
setQueueEdit: (index: null | number) => void
|
|
syncQueue: () => void
|
|
}
|
|
|
|
export interface ComposerRefs {
|
|
historyDraftRef: MutableRefObject<string>
|
|
historyRef: MutableRefObject<string[]>
|
|
queueEditRef: MutableRefObject<null | number>
|
|
queueRef: MutableRefObject<string[]>
|
|
submitRef: MutableRefObject<(value: string) => void>
|
|
}
|
|
|
|
export interface ComposerState {
|
|
compIdx: number
|
|
compReplace: number
|
|
completions: CompletionItem[]
|
|
historyIdx: null | number
|
|
input: string
|
|
inputBuf: string[]
|
|
pasteSnips: PasteSnippet[]
|
|
queueEditIdx: null | number
|
|
queuedDisplay: string[]
|
|
}
|
|
|
|
export interface UseComposerStateOptions {
|
|
gw: GatewayClient
|
|
onClipboardPaste: (quiet?: boolean) => Promise<void> | void
|
|
onImageAttached?: (info: ImageAttachResponse) => void
|
|
submitRef: MutableRefObject<(value: string) => void>
|
|
}
|
|
|
|
export interface UseComposerStateResult {
|
|
actions: ComposerActions
|
|
refs: ComposerRefs
|
|
state: ComposerState
|
|
}
|
|
|
|
export interface InputHandlerActions {
|
|
answerClarify: (answer: string) => void
|
|
appendMessage: (msg: Msg) => void
|
|
die: () => void
|
|
dispatchSubmission: (full: string) => void
|
|
guardBusySessionSwitch: (what?: string) => boolean
|
|
newSession: (msg?: string, title?: string) => void
|
|
sys: (text: string) => void
|
|
}
|
|
|
|
export interface InputHandlerContext {
|
|
actions: InputHandlerActions
|
|
composer: {
|
|
actions: ComposerActions
|
|
refs: ComposerRefs
|
|
state: ComposerState
|
|
}
|
|
gateway: GatewayServices
|
|
terminal: {
|
|
hasSelection: boolean
|
|
scrollRef: RefObject<null | ScrollBoxHandle>
|
|
scrollWithSelection: (delta: number) => void
|
|
selection: SelectionApi
|
|
stdout?: NodeJS.WriteStream
|
|
}
|
|
voice: {
|
|
enabled: boolean
|
|
recordKey: ParsedVoiceRecordKey
|
|
recording: boolean
|
|
setProcessing: StateSetter<boolean>
|
|
setRecording: StateSetter<boolean>
|
|
setVoiceEnabled: StateSetter<boolean>
|
|
}
|
|
wheelStep: number
|
|
}
|
|
|
|
export interface InputHandlerResult {
|
|
pagerPageSize: number
|
|
}
|
|
|
|
export interface GatewayEventHandlerContext {
|
|
composer: {
|
|
setInput: StateSetter<string>
|
|
}
|
|
gateway: GatewayServices
|
|
session: {
|
|
STARTUP_RESUME_ID: string
|
|
colsRef: MutableRefObject<number>
|
|
newSession: (msg?: string, title?: string) => void
|
|
resetSession: () => void
|
|
resumeById: (id: string) => void
|
|
setCatalog: StateSetter<null | SlashCatalog>
|
|
}
|
|
submission: {
|
|
submitRef: MutableRefObject<(value: string) => void>
|
|
}
|
|
system: {
|
|
bellOnComplete: boolean
|
|
stdout?: NodeJS.WriteStream
|
|
sys: (text: string) => void
|
|
}
|
|
transcript: {
|
|
appendMessage: (msg: Msg) => void
|
|
panel: (title: string, sections: PanelSection[]) => void
|
|
setHistoryItems: StateSetter<Msg[]>
|
|
}
|
|
voice: {
|
|
setProcessing: StateSetter<boolean>
|
|
setRecording: StateSetter<boolean>
|
|
setVoiceEnabled: StateSetter<boolean>
|
|
}
|
|
}
|
|
|
|
export interface SlashHandlerContext {
|
|
composer: {
|
|
enqueue: (text: string) => void
|
|
hasSelection: boolean
|
|
paste: (quiet?: boolean) => void
|
|
queueRef: MutableRefObject<string[]>
|
|
selection: SelectionApi
|
|
setInput: StateSetter<string>
|
|
}
|
|
gateway: GatewayServices
|
|
local: {
|
|
catalog: null | SlashCatalog
|
|
getHistoryItems: () => Msg[]
|
|
getLastUserMsg: () => string
|
|
maybeWarn: (value: unknown) => void
|
|
setCatalog: StateSetter<null | SlashCatalog>
|
|
}
|
|
session: {
|
|
closeSession: (targetSid?: null | string) => Promise<unknown>
|
|
die: () => void
|
|
dieWithCode: (code: number) => void
|
|
guardBusySessionSwitch: (what?: string) => boolean
|
|
newSession: (msg?: string, title?: string) => void
|
|
resetVisibleHistory: (info?: null | SessionInfo) => void
|
|
resumeById: (id: string) => void
|
|
setSessionStartedAt: StateSetter<number>
|
|
}
|
|
slashFlightRef: MutableRefObject<number>
|
|
transcript: {
|
|
page: (text: string, title?: string) => void
|
|
panel: (title: string, sections: PanelSection[]) => void
|
|
send: (text: string) => void
|
|
setHistoryItems: StateSetter<Msg[]>
|
|
sys: (text: string) => void
|
|
trimLastExchange: (items: Msg[]) => Msg[]
|
|
}
|
|
voice: {
|
|
setVoiceEnabled: StateSetter<boolean>
|
|
setVoiceRecordKey: (v: ParsedVoiceRecordKey) => void
|
|
}
|
|
}
|
|
|
|
export interface AppLayoutActions {
|
|
answerApproval: (choice: string) => void
|
|
answerClarify: (answer: string) => void
|
|
answerSecret: (value: string) => void
|
|
answerSudo: (pw: string) => void
|
|
clearSelection: () => void
|
|
onModelSelect: (value: string) => void
|
|
resumeById: (id: string) => void
|
|
setStickyPrompt: (value: string) => void
|
|
}
|
|
|
|
export interface AppLayoutComposerProps {
|
|
cols: number
|
|
compIdx: number
|
|
completions: CompletionItem[]
|
|
empty: boolean
|
|
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
|
|
input: string
|
|
inputBuf: string[]
|
|
pagerPageSize: number
|
|
queueEditIdx: null | number
|
|
queuedDisplay: string[]
|
|
submit: (value: string) => void
|
|
updateInput: StateSetter<string>
|
|
voiceRecordKey: ParsedVoiceRecordKey
|
|
}
|
|
|
|
export interface AppLayoutProgressProps {
|
|
showProgressArea: boolean
|
|
}
|
|
|
|
export interface AppLayoutStatusProps {
|
|
cwdLabel: string
|
|
goodVibesTick: number
|
|
sessionStartedAt: null | number
|
|
showStickyPrompt: boolean
|
|
statusColor: string
|
|
stickyPrompt: string
|
|
turnStartedAt: null | number
|
|
voiceLabel: string
|
|
}
|
|
|
|
export interface AppLayoutTranscriptProps {
|
|
historyItems: Msg[]
|
|
scrollRef: RefObject<null | ScrollBoxHandle>
|
|
virtualHistory: VirtualHistoryState
|
|
virtualRows: TranscriptRow[]
|
|
}
|
|
|
|
export interface AppLayoutProps {
|
|
actions: AppLayoutActions
|
|
composer: AppLayoutComposerProps
|
|
mouseTracking: MouseTrackingMode
|
|
progress: AppLayoutProgressProps
|
|
status: AppLayoutStatusProps
|
|
transcript: AppLayoutTranscriptProps
|
|
}
|
|
|
|
export interface AppOverlaysProps {
|
|
cols: number
|
|
compIdx: number
|
|
completions: CompletionItem[]
|
|
onApprovalChoice: (choice: string) => void
|
|
onClarifyAnswer: (value: string) => void
|
|
onModelSelect: (value: string) => void
|
|
onPickerSelect: (sessionId: string) => void
|
|
onSecretSubmit: (value: string) => void
|
|
onSudoSubmit: (pw: string) => void
|
|
pagerPageSize: number
|
|
}
|
|
|
|
export interface PasteSnippet {
|
|
label: string
|
|
path?: string
|
|
text: string
|
|
}
|