mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 08:15:53 +00:00
feat: 第一个可以用的 ink 组件抽象 (#158)
This commit is contained in:
57
packages/@ant/ink/src/hooks/use-animation-frame.ts
Normal file
57
packages/@ant/ink/src/hooks/use-animation-frame.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { ClockContext } from '../components/ClockContext.js'
|
||||
import type { DOMElement } from '../core/dom.js'
|
||||
import { useTerminalViewport } from './use-terminal-viewport.js'
|
||||
|
||||
/**
|
||||
* Hook for synchronized animations that pause when offscreen.
|
||||
*
|
||||
* Returns a ref to attach to the animated element and the current animation time.
|
||||
* All instances share the same clock, so animations stay in sync.
|
||||
* The clock only runs when at least one keepAlive subscriber exists.
|
||||
*
|
||||
* Pass `null` to pause — unsubscribes from the clock so no ticks fire.
|
||||
* Time freezes at the last value and resumes from the current clock time
|
||||
* when a number is passed again.
|
||||
*
|
||||
* @param intervalMs - How often to update, or null to pause
|
||||
* @returns [ref, time] - Ref to attach to element, elapsed time in ms
|
||||
*
|
||||
* @example
|
||||
* function Spinner() {
|
||||
* const [ref, time] = useAnimationFrame(120)
|
||||
* const frame = Math.floor(time / 120) % FRAMES.length
|
||||
* return <Box ref={ref}>{FRAMES[frame]}</Box>
|
||||
* }
|
||||
*
|
||||
* The clock automatically slows when the terminal is blurred,
|
||||
* so consumers don't need to handle focus state.
|
||||
*/
|
||||
export function useAnimationFrame(
|
||||
intervalMs: number | null = 16,
|
||||
): [ref: (element: DOMElement | null) => void, time: number] {
|
||||
const clock = useContext(ClockContext)
|
||||
const [viewportRef, { isVisible }] = useTerminalViewport()
|
||||
const [time, setTime] = useState(() => clock?.now() ?? 0)
|
||||
|
||||
const active = isVisible && intervalMs !== null
|
||||
|
||||
useEffect(() => {
|
||||
if (!clock || !active) return
|
||||
|
||||
let lastUpdate = clock.now()
|
||||
|
||||
const onChange = (): void => {
|
||||
const now = clock.now()
|
||||
if (now - lastUpdate >= intervalMs!) {
|
||||
lastUpdate = now
|
||||
setTime(now)
|
||||
}
|
||||
}
|
||||
|
||||
// keepAlive: true — visible animations drive the clock
|
||||
return clock.subscribe(onChange, true)
|
||||
}, [clock, intervalMs, active])
|
||||
|
||||
return [viewportRef, time]
|
||||
}
|
||||
8
packages/@ant/ink/src/hooks/use-app.ts
Normal file
8
packages/@ant/ink/src/hooks/use-app.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useContext } from 'react'
|
||||
import AppContext from '../components/AppContext.js'
|
||||
|
||||
/**
|
||||
* `useApp` is a React hook, which exposes a method to manually exit the app (unmount).
|
||||
*/
|
||||
const useApp = () => useContext(AppContext)
|
||||
export default useApp
|
||||
73
packages/@ant/ink/src/hooks/use-declared-cursor.ts
Normal file
73
packages/@ant/ink/src/hooks/use-declared-cursor.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
|
||||
import CursorDeclarationContext from '../components/CursorDeclarationContext.js'
|
||||
import type { DOMElement } from '../core/dom.js'
|
||||
|
||||
/**
|
||||
* Declares where the terminal cursor should be parked after each frame.
|
||||
*
|
||||
* Terminal emulators render IME preedit text at the physical cursor
|
||||
* position, and screen readers / screen magnifiers track the native
|
||||
* cursor — so parking it at the text input's caret makes CJK input
|
||||
* appear inline and lets accessibility tools follow the input.
|
||||
*
|
||||
* Returns a ref callback to attach to the Box that contains the input.
|
||||
* The declared (line, column) is interpreted relative to that Box's
|
||||
* nodeCache rect (populated by renderNodeToOutput).
|
||||
*
|
||||
* Timing: Both ref attach and useLayoutEffect fire in React's layout
|
||||
* phase — after resetAfterCommit calls scheduleRender. scheduleRender
|
||||
* defers onRender via queueMicrotask, so onRender runs AFTER layout
|
||||
* effects commit and reads the fresh declaration on the first frame
|
||||
* (no one-keystroke lag). Test env uses onImmediateRender (synchronous,
|
||||
* no microtask), so tests compensate by calling ink.onRender()
|
||||
* explicitly after render.
|
||||
*/
|
||||
export function useDeclaredCursor({
|
||||
line,
|
||||
column,
|
||||
active,
|
||||
}: {
|
||||
line: number
|
||||
column: number
|
||||
active: boolean
|
||||
}): (element: DOMElement | null) => void {
|
||||
const setCursorDeclaration = useContext(CursorDeclarationContext)
|
||||
const nodeRef = useRef<DOMElement | null>(null)
|
||||
|
||||
const setNode = useCallback((node: DOMElement | null) => {
|
||||
nodeRef.current = node
|
||||
}, [])
|
||||
|
||||
// When active, set unconditionally. When inactive, clear conditionally
|
||||
// (only if the currently-declared node is ours). The node-identity check
|
||||
// handles two hazards:
|
||||
// 1. A memo()ized active instance elsewhere (e.g. the search input in
|
||||
// a memo'd Footer) doesn't re-render this commit — an inactive
|
||||
// instance re-rendering here must not clobber it.
|
||||
// 2. Sibling handoff (menu focus moving between list items) — when
|
||||
// focus moves opposite to sibling order, the newly-inactive item's
|
||||
// effect runs AFTER the newly-active item's set. Without the node
|
||||
// check it would clobber.
|
||||
// No dep array: must re-declare every commit so the active instance
|
||||
// re-claims the declaration after another instance's unmount-cleanup or
|
||||
// sibling handoff nulls it.
|
||||
useLayoutEffect(() => {
|
||||
const node = nodeRef.current
|
||||
if (active && node) {
|
||||
setCursorDeclaration({ relativeX: column, relativeY: line, node })
|
||||
} else {
|
||||
setCursorDeclaration(null, node)
|
||||
}
|
||||
})
|
||||
|
||||
// Clear on unmount (conditionally — another instance may own by then).
|
||||
// Separate effect with empty deps so cleanup only fires once — not on
|
||||
// every line/column change, which would transiently null between commits.
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
setCursorDeclaration(null, nodeRef.current)
|
||||
}
|
||||
}, [setCursorDeclaration])
|
||||
|
||||
return setNode
|
||||
}
|
||||
92
packages/@ant/ink/src/hooks/use-input.ts
Normal file
92
packages/@ant/ink/src/hooks/use-input.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useLayoutEffect } from 'react'
|
||||
import { useEventCallback } from 'usehooks-ts'
|
||||
import type { InputEvent, Key } from '../core/events/input-event.js'
|
||||
import useStdin from './use-stdin.js'
|
||||
|
||||
type Handler = (input: string, key: Key, event: InputEvent) => void
|
||||
|
||||
type Options = {
|
||||
/**
|
||||
* Enable or disable capturing of user input.
|
||||
* Useful when there are multiple useInput hooks used at once to avoid handling the same input several times.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook is used for handling user input.
|
||||
* It's a more convenient alternative to using `StdinContext` and listening to `data` events.
|
||||
* The callback you pass to `useInput` is called for each character when user enters any input.
|
||||
* However, if user pastes text and it's more than one character, the callback will be called only once and the whole string will be passed as `input`.
|
||||
*
|
||||
* ```
|
||||
* import {useInput} from 'ink';
|
||||
*
|
||||
* const UserInput = () => {
|
||||
* useInput((input, key) => {
|
||||
* if (input === 'q') {
|
||||
* // Exit program
|
||||
* }
|
||||
*
|
||||
* if (key.leftArrow) {
|
||||
* // Left arrow key pressed
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* return …
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
const useInput = (inputHandler: Handler, options: Options = {}) => {
|
||||
const { setRawMode, internal_exitOnCtrlC, internal_eventEmitter } = useStdin()
|
||||
|
||||
// useLayoutEffect (not useEffect) so that raw mode is enabled synchronously
|
||||
// during React's commit phase, before render() returns. With useEffect, raw
|
||||
// mode setup is deferred to the next event loop tick via React's scheduler,
|
||||
// leaving the terminal in cooked mode — keystrokes echo and the cursor is
|
||||
// visible until the effect fires.
|
||||
useLayoutEffect(() => {
|
||||
if (options.isActive === false) {
|
||||
return
|
||||
}
|
||||
|
||||
setRawMode(true)
|
||||
|
||||
return () => {
|
||||
setRawMode(false)
|
||||
}
|
||||
}, [options.isActive, setRawMode])
|
||||
|
||||
// Register the listener once on mount so its slot in the EventEmitter's
|
||||
// listener array is stable. If isActive were in the effect's deps, the
|
||||
// listener would re-append on false→true, moving it behind listeners
|
||||
// that registered while it was inactive — breaking
|
||||
// stopImmediatePropagation() ordering. useEventCallback keeps the
|
||||
// reference stable while reading latest isActive/inputHandler from
|
||||
// closure (it syncs via useLayoutEffect, so it's compiler-safe).
|
||||
const handleData = useEventCallback((event: InputEvent) => {
|
||||
if (options.isActive === false) {
|
||||
return
|
||||
}
|
||||
const { input, key } = event
|
||||
|
||||
// If app is not supposed to exit on Ctrl+C, then let input listener handle it
|
||||
// Note: discreteUpdates is called at the App level when emitting events,
|
||||
// so all listeners are already within a high-priority update context.
|
||||
if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) {
|
||||
inputHandler(input, key, event)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
internal_eventEmitter?.on('input', handleData)
|
||||
|
||||
return () => {
|
||||
internal_eventEmitter?.removeListener('input', handleData)
|
||||
}
|
||||
}, [internal_eventEmitter, handleData])
|
||||
}
|
||||
|
||||
export default useInput
|
||||
67
packages/@ant/ink/src/hooks/use-interval.ts
Normal file
67
packages/@ant/ink/src/hooks/use-interval.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
import { ClockContext } from '../components/ClockContext.js'
|
||||
|
||||
/**
|
||||
* Returns the clock time, updating at the given interval.
|
||||
* Subscribes as non-keepAlive — won't keep the clock alive on its own,
|
||||
* but updates whenever a keepAlive subscriber (e.g. the spinner)
|
||||
* is driving the clock.
|
||||
*
|
||||
* Use this to drive pure time-based computations (shimmer position,
|
||||
* frame index) from the shared clock.
|
||||
*/
|
||||
export function useAnimationTimer(intervalMs: number): number {
|
||||
const clock = useContext(ClockContext)
|
||||
const [time, setTime] = useState(() => clock?.now() ?? 0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!clock) return
|
||||
|
||||
let lastUpdate = clock.now()
|
||||
|
||||
const onChange = (): void => {
|
||||
const now = clock.now()
|
||||
if (now - lastUpdate >= intervalMs) {
|
||||
lastUpdate = now
|
||||
setTime(now)
|
||||
}
|
||||
}
|
||||
|
||||
return clock.subscribe(onChange, false)
|
||||
}, [clock, intervalMs])
|
||||
|
||||
return time
|
||||
}
|
||||
|
||||
/**
|
||||
* Interval hook backed by the shared Clock.
|
||||
*
|
||||
* Unlike `useInterval` from `usehooks-ts` (which creates its own setInterval),
|
||||
* this piggybacks on the single shared clock so all timers consolidate into
|
||||
* one wake-up. Pass `null` for intervalMs to pause.
|
||||
*/
|
||||
export function useInterval(
|
||||
callback: () => void,
|
||||
intervalMs: number | null,
|
||||
): void {
|
||||
const callbackRef = useRef(callback)
|
||||
callbackRef.current = callback
|
||||
|
||||
const clock = useContext(ClockContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (!clock || intervalMs === null) return
|
||||
|
||||
let lastUpdate = clock.now()
|
||||
|
||||
const onChange = (): void => {
|
||||
const now = clock.now()
|
||||
if (now - lastUpdate >= intervalMs) {
|
||||
lastUpdate = now
|
||||
callbackRef.current()
|
||||
}
|
||||
}
|
||||
|
||||
return clock.subscribe(onChange, false)
|
||||
}, [clock, intervalMs])
|
||||
}
|
||||
53
packages/@ant/ink/src/hooks/use-search-highlight.ts
Normal file
53
packages/@ant/ink/src/hooks/use-search-highlight.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useContext, useMemo } from 'react'
|
||||
import StdinContext from '../components/StdinContext.js'
|
||||
import type { DOMElement } from '../core/dom.js'
|
||||
import instances from '../core/instances.js'
|
||||
import type { MatchPosition } from '../core/render-to-screen.js'
|
||||
|
||||
/**
|
||||
* Set the search highlight query on the Ink instance. Non-empty → all
|
||||
* visible occurrences are inverted on the next frame (SGR 7, screen-buffer
|
||||
* overlay, same damage machinery as selection). Empty → clears.
|
||||
*
|
||||
* This is a screen-space highlight — it matches the RENDERED text, not the
|
||||
* source message text. Works for anything visible (bash output, file paths,
|
||||
* error messages) regardless of where it came from in the message tree. A
|
||||
* query that matched in source but got truncated/ellipsized in rendering
|
||||
* won't highlight; that's acceptable — we highlight what you see.
|
||||
*/
|
||||
export function useSearchHighlight(): {
|
||||
setQuery: (query: string) => void
|
||||
/** Paint an existing DOM subtree (from the MAIN tree) to a fresh
|
||||
* Screen at its natural height, scan. Element-relative positions
|
||||
* (row 0 = element top). Zero context duplication — the element
|
||||
* IS the one built with all real providers. */
|
||||
scanElement: (el: DOMElement) => MatchPosition[]
|
||||
/** Position-based CURRENT highlight. Every frame writes yellow at
|
||||
* positions[currentIdx] + rowOffset. The scan-highlight (inverse on
|
||||
* all matches) still runs — this overlays on top. rowOffset tracks
|
||||
* scroll; positions stay stable (message-relative). null clears. */
|
||||
setPositions: (
|
||||
state: {
|
||||
positions: MatchPosition[]
|
||||
rowOffset: number
|
||||
currentIdx: number
|
||||
} | null,
|
||||
) => void
|
||||
} {
|
||||
useContext(StdinContext) // anchor to App subtree for hook rules
|
||||
const ink = instances.get(process.stdout)
|
||||
return useMemo(() => {
|
||||
if (!ink) {
|
||||
return {
|
||||
setQuery: () => {},
|
||||
scanElement: () => [],
|
||||
setPositions: () => {},
|
||||
}
|
||||
}
|
||||
return {
|
||||
setQuery: (query: string) => ink.setSearchHighlight(query),
|
||||
scanElement: (el: DOMElement) => ink.scanElementSubtree(el),
|
||||
setPositions: state => ink.setSearchPositions(state),
|
||||
}
|
||||
}, [ink])
|
||||
}
|
||||
104
packages/@ant/ink/src/hooks/use-selection.ts
Normal file
104
packages/@ant/ink/src/hooks/use-selection.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useContext, useMemo, useSyncExternalStore } from 'react'
|
||||
import StdinContext from '../components/StdinContext.js'
|
||||
import instances from '../core/instances.js'
|
||||
import {
|
||||
type FocusMove,
|
||||
type SelectionState,
|
||||
shiftAnchor,
|
||||
} from '../core/selection.js'
|
||||
|
||||
/**
|
||||
* Access to text selection operations on the Ink instance (fullscreen only).
|
||||
* Returns no-op functions when fullscreen mode is disabled.
|
||||
*/
|
||||
export function useSelection(): {
|
||||
copySelection: () => string
|
||||
/** Copy without clearing the highlight (for copy-on-select). */
|
||||
copySelectionNoClear: () => string
|
||||
clearSelection: () => void
|
||||
hasSelection: () => boolean
|
||||
/** Read the raw mutable selection state (for drag-to-scroll). */
|
||||
getState: () => SelectionState | null
|
||||
/** Subscribe to selection mutations (start/update/finish/clear). */
|
||||
subscribe: (cb: () => void) => () => void
|
||||
/** Shift the anchor row by dRow, clamped to [minRow, maxRow]. */
|
||||
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||
/** Shift anchor AND focus by dRow (keyboard scroll: whole selection
|
||||
* tracks content). Clamped points get col reset to the full-width edge
|
||||
* since their content was captured by captureScrolledRows. Reads
|
||||
* screen.width from the ink instance for the col-reset boundary. */
|
||||
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||
/** Keyboard selection extension (shift+arrow): move focus, anchor fixed.
|
||||
* Left/right wrap across rows; up/down clamp at viewport edges. */
|
||||
moveFocus: (move: FocusMove) => void
|
||||
/** Capture text from rows about to scroll out of the viewport (call
|
||||
* BEFORE scrollBy so the screen buffer still has the outgoing rows). */
|
||||
captureScrolledRows: (
|
||||
firstRow: number,
|
||||
lastRow: number,
|
||||
side: 'above' | 'below',
|
||||
) => void
|
||||
/** Set the selection highlight bg color (theme-piping; solid bg
|
||||
* replaces the old SGR-7 inverse so syntax highlighting stays readable
|
||||
* under selection). Call once on mount + whenever theme changes. */
|
||||
setSelectionBgColor: (color: string) => void
|
||||
} {
|
||||
// Look up the Ink instance via stdout — same pattern as instances map.
|
||||
// StdinContext is available (it's always provided), and the Ink instance
|
||||
// is keyed by stdout which we can get from process.stdout since there's
|
||||
// only one Ink instance per process in practice.
|
||||
useContext(StdinContext) // anchor to App subtree for hook rules
|
||||
const ink = instances.get(process.stdout)
|
||||
// Memoize so callers can safely use the return value in dependency arrays.
|
||||
// ink is a singleton per stdout — stable across renders.
|
||||
return useMemo(() => {
|
||||
if (!ink) {
|
||||
return {
|
||||
copySelection: () => '',
|
||||
copySelectionNoClear: () => '',
|
||||
clearSelection: () => {},
|
||||
hasSelection: () => false,
|
||||
getState: () => null,
|
||||
subscribe: () => () => {},
|
||||
shiftAnchor: () => {},
|
||||
shiftSelection: () => {},
|
||||
moveFocus: () => {},
|
||||
captureScrolledRows: () => {},
|
||||
setSelectionBgColor: () => {},
|
||||
}
|
||||
}
|
||||
return {
|
||||
copySelection: () => ink.copySelection(),
|
||||
copySelectionNoClear: () => ink.copySelectionNoClear(),
|
||||
clearSelection: () => ink.clearTextSelection(),
|
||||
hasSelection: () => ink.hasTextSelection(),
|
||||
getState: () => ink.selection,
|
||||
subscribe: (cb: () => void) => ink.subscribeToSelectionChange(cb),
|
||||
shiftAnchor: (dRow: number, minRow: number, maxRow: number) =>
|
||||
shiftAnchor(ink.selection, dRow, minRow, maxRow),
|
||||
shiftSelection: (dRow, minRow, maxRow) =>
|
||||
ink.shiftSelectionForScroll(dRow, minRow, maxRow),
|
||||
moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move),
|
||||
captureScrolledRows: (firstRow, lastRow, side) =>
|
||||
ink.captureScrolledRows(firstRow, lastRow, side),
|
||||
setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color),
|
||||
}
|
||||
}, [ink])
|
||||
}
|
||||
|
||||
const NO_SUBSCRIBE = () => () => {}
|
||||
const ALWAYS_FALSE = () => false
|
||||
|
||||
/**
|
||||
* Reactive selection-exists state. Re-renders the caller when a text
|
||||
* selection is created or cleared. Always returns false outside
|
||||
* fullscreen mode (selection is only available in alt-screen).
|
||||
*/
|
||||
export function useHasSelection(): boolean {
|
||||
useContext(StdinContext)
|
||||
const ink = instances.get(process.stdout)
|
||||
return useSyncExternalStore(
|
||||
ink ? ink.subscribeToSelectionChange : NO_SUBSCRIBE,
|
||||
ink ? ink.hasTextSelection : ALWAYS_FALSE,
|
||||
)
|
||||
}
|
||||
8
packages/@ant/ink/src/hooks/use-stdin.ts
Normal file
8
packages/@ant/ink/src/hooks/use-stdin.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useContext } from 'react'
|
||||
import StdinContext from '../components/StdinContext.js'
|
||||
|
||||
/**
|
||||
* `useStdin` is a React hook, which exposes stdin stream.
|
||||
*/
|
||||
const useStdin = () => useContext(StdinContext)
|
||||
export default useStdin
|
||||
72
packages/@ant/ink/src/hooks/use-tab-status.ts
Normal file
72
packages/@ant/ink/src/hooks/use-tab-status.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useContext, useEffect, useRef } from 'react'
|
||||
import {
|
||||
CLEAR_TAB_STATUS,
|
||||
supportsTabStatus,
|
||||
tabStatus,
|
||||
wrapForMultiplexer,
|
||||
} from '../core/termio/osc.js'
|
||||
import type { Color } from '../core/termio/types.js'
|
||||
import { TerminalWriteContext } from './useTerminalNotification.js'
|
||||
|
||||
export type TabStatusKind = 'idle' | 'busy' | 'waiting'
|
||||
|
||||
const rgb = (r: number, g: number, b: number): Color => ({
|
||||
type: 'rgb',
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
})
|
||||
|
||||
// Per the OSC 21337 usage guide's suggested mapping.
|
||||
const TAB_STATUS_PRESETS: Record<
|
||||
TabStatusKind,
|
||||
{ indicator: Color; status: string; statusColor: Color }
|
||||
> = {
|
||||
idle: {
|
||||
indicator: rgb(0, 215, 95),
|
||||
status: 'Idle',
|
||||
statusColor: rgb(136, 136, 136),
|
||||
},
|
||||
busy: {
|
||||
indicator: rgb(255, 149, 0),
|
||||
status: 'Working…',
|
||||
statusColor: rgb(255, 149, 0),
|
||||
},
|
||||
waiting: {
|
||||
indicator: rgb(95, 135, 255),
|
||||
status: 'Waiting',
|
||||
statusColor: rgb(95, 135, 255),
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Declaratively set the tab-status indicator (OSC 21337).
|
||||
*
|
||||
* Emits a colored dot + short status text to the tab sidebar. Terminals
|
||||
* that don't support OSC 21337 discard the sequence silently, so this is
|
||||
* safe to call unconditionally. Wrapped for tmux/screen passthrough.
|
||||
*
|
||||
* Pass `null` to opt out. If a status was previously set, transitioning to
|
||||
* `null` emits CLEAR_TAB_STATUS so toggling off mid-session doesn't leave
|
||||
* a stale dot. Process-exit cleanup is handled by ink.tsx's unmount path.
|
||||
*/
|
||||
export function useTabStatus(kind: TabStatusKind | null): void {
|
||||
const writeRaw = useContext(TerminalWriteContext)
|
||||
const prevKindRef = useRef<TabStatusKind | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// When kind transitions from non-null to null (e.g. user toggles off
|
||||
// showStatusInTerminalTab mid-session), clear the stale dot.
|
||||
if (kind === null) {
|
||||
if (prevKindRef.current !== null && writeRaw && supportsTabStatus()) {
|
||||
writeRaw(wrapForMultiplexer(CLEAR_TAB_STATUS))
|
||||
}
|
||||
prevKindRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
prevKindRef.current = kind
|
||||
if (!writeRaw || !supportsTabStatus()) return
|
||||
writeRaw(wrapForMultiplexer(tabStatus(TAB_STATUS_PRESETS[kind])))
|
||||
}, [kind, writeRaw])
|
||||
}
|
||||
16
packages/@ant/ink/src/hooks/use-terminal-focus.ts
Normal file
16
packages/@ant/ink/src/hooks/use-terminal-focus.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useContext } from 'react'
|
||||
import TerminalFocusContext from '../components/TerminalFocusContext.js'
|
||||
|
||||
/**
|
||||
* Hook to check if the terminal has focus.
|
||||
*
|
||||
* Uses DECSET 1004 focus reporting - the terminal sends escape sequences
|
||||
* when it gains or loses focus. These are handled automatically
|
||||
* by Ink and filtered from useInput.
|
||||
*
|
||||
* @returns true if the terminal is focused (or focus state is unknown)
|
||||
*/
|
||||
export function useTerminalFocus(): boolean {
|
||||
const { isTerminalFocused } = useContext(TerminalFocusContext)
|
||||
return isTerminalFocused
|
||||
}
|
||||
31
packages/@ant/ink/src/hooks/use-terminal-title.ts
Normal file
31
packages/@ant/ink/src/hooks/use-terminal-title.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useContext, useEffect } from 'react'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
import { OSC, osc } from '../core/termio/osc.js'
|
||||
import { TerminalWriteContext } from './useTerminalNotification.js'
|
||||
|
||||
/**
|
||||
* Declaratively set the terminal tab/window title.
|
||||
*
|
||||
* Pass a string to set the title. ANSI escape sequences are stripped
|
||||
* automatically so callers don't need to know about terminal encoding.
|
||||
* Pass `null` to opt out — the hook becomes a no-op and leaves the
|
||||
* terminal title untouched.
|
||||
*
|
||||
* On Windows, uses `process.title` (classic conhost doesn't support OSC).
|
||||
* Elsewhere, writes OSC 0 (set title+icon) via Ink's stdout.
|
||||
*/
|
||||
export function useTerminalTitle(title: string | null): void {
|
||||
const writeRaw = useContext(TerminalWriteContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (title === null || !writeRaw) return
|
||||
|
||||
const clean = stripAnsi(title)
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
process.title = clean
|
||||
} else {
|
||||
writeRaw(osc(OSC.SET_TITLE_AND_ICON, clean))
|
||||
}
|
||||
}, [title, writeRaw])
|
||||
}
|
||||
96
packages/@ant/ink/src/hooks/use-terminal-viewport.ts
Normal file
96
packages/@ant/ink/src/hooks/use-terminal-viewport.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
|
||||
import { TerminalSizeContext } from '../components/TerminalSizeContext.js'
|
||||
import type { DOMElement } from '../core/dom.js'
|
||||
|
||||
type ViewportEntry = {
|
||||
/**
|
||||
* Whether the element is currently within the terminal viewport
|
||||
*/
|
||||
isVisible: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect if a component is within the terminal viewport.
|
||||
*
|
||||
* Returns a callback ref and a viewport entry object.
|
||||
* Attach the ref to the component you want to track.
|
||||
*
|
||||
* The entry is updated during the layout phase (useLayoutEffect) so callers
|
||||
* always read fresh values during render. Visibility changes do NOT trigger
|
||||
* re-renders on their own — callers that re-render for other reasons (e.g.
|
||||
* animation ticks, state changes) will pick up the latest value naturally.
|
||||
* This avoids infinite update loops when combined with other layout effects
|
||||
* that also call setState.
|
||||
*
|
||||
* @example
|
||||
* const [ref, entry] = useTerminalViewport()
|
||||
* return <Box ref={ref}><Animation enabled={entry.isVisible}>...</Animation></Box>
|
||||
*/
|
||||
export function useTerminalViewport(): [
|
||||
ref: (element: DOMElement | null) => void,
|
||||
entry: ViewportEntry,
|
||||
] {
|
||||
const terminalSize = useContext(TerminalSizeContext)
|
||||
const elementRef = useRef<DOMElement | null>(null)
|
||||
const entryRef = useRef<ViewportEntry>({ isVisible: true })
|
||||
|
||||
const setElement = useCallback((el: DOMElement | null) => {
|
||||
elementRef.current = el
|
||||
}, [])
|
||||
|
||||
// Runs on every render because yoga layout values can change
|
||||
// without React being aware. Only updates the ref — no setState
|
||||
// to avoid cascading re-renders during the commit phase.
|
||||
// Walks the DOM ancestor chain fresh each time to avoid holding stale
|
||||
// references after yoga tree rebuilds.
|
||||
useLayoutEffect(() => {
|
||||
const element = elementRef.current
|
||||
if (!element?.yogaNode || !terminalSize) {
|
||||
return
|
||||
}
|
||||
|
||||
const height = element.yogaNode.getComputedHeight()
|
||||
const rows = terminalSize.rows
|
||||
|
||||
// Walk the DOM parent chain (not yoga.getParent()) so we can detect
|
||||
// scroll containers and subtract their scrollTop. Yoga computes layout
|
||||
// positions without scroll offset — scrollTop is applied at render time.
|
||||
// Without this, an element inside a ScrollBox whose yoga position exceeds
|
||||
// terminalRows would be considered offscreen even when scrolled into view
|
||||
// (e.g., the spinner in fullscreen mode after enough messages accumulate).
|
||||
let absoluteTop = element.yogaNode.getComputedTop()
|
||||
let parent: DOMElement | undefined = element.parentNode
|
||||
let root = element.yogaNode
|
||||
while (parent) {
|
||||
if (parent.yogaNode) {
|
||||
absoluteTop += parent.yogaNode.getComputedTop()
|
||||
root = parent.yogaNode
|
||||
}
|
||||
// scrollTop is only ever set on scroll containers (by ScrollBox + renderer).
|
||||
// Non-scroll nodes have undefined scrollTop → falsy fast-path.
|
||||
if (parent.scrollTop) absoluteTop -= parent.scrollTop
|
||||
parent = parent.parentNode
|
||||
}
|
||||
|
||||
// Only the root's height matters
|
||||
const screenHeight = root.getComputedHeight()
|
||||
|
||||
const bottom = absoluteTop + height
|
||||
// When content overflows the viewport (screenHeight > rows), the
|
||||
// cursor-restore at frame end scrolls one extra row into scrollback.
|
||||
// log-update.ts accounts for this with scrollbackRows = viewportY + 1.
|
||||
// We must match, otherwise an element at the boundary is considered
|
||||
// "visible" here (animation keeps ticking) but its row is treated as
|
||||
// scrollback by log-update (content change → full reset → flicker).
|
||||
const cursorRestoreScroll = screenHeight > rows ? 1 : 0
|
||||
const viewportY = Math.max(0, screenHeight - rows) + cursorRestoreScroll
|
||||
const viewportBottom = viewportY + rows
|
||||
const visible = bottom > viewportY && absoluteTop < viewportBottom
|
||||
|
||||
if (visible !== entryRef.current.isVisible) {
|
||||
entryRef.current = { isVisible: visible }
|
||||
}
|
||||
})
|
||||
|
||||
return [setElement, entryRef.current]
|
||||
}
|
||||
95
packages/@ant/ink/src/hooks/useExitOnCtrlCD.ts
Normal file
95
packages/@ant/ink/src/hooks/useExitOnCtrlCD.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Minimal stub of useExitOnCtrlCD + useExitOnCtrlCDWithKeybindings.
|
||||
*
|
||||
* The original hooks depend on the keybinding system and useApp() exit.
|
||||
* This stub provides the same interface with simplified Ctrl+C/D handling
|
||||
* via useInput, suitable for the standalone @anthropic/ink package.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import useInput from './use-input.js'
|
||||
|
||||
export type ExitState = {
|
||||
pending: boolean
|
||||
keyName: 'Ctrl-C' | 'Ctrl-D' | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal double-press exit handler.
|
||||
* First Ctrl+C/D shows pending state, second press within timeout fires onExit.
|
||||
*/
|
||||
const DOUBLE_PRESS_TIMEOUT_MS = 800
|
||||
|
||||
function useDoublePress(
|
||||
setPending: (pending: boolean) => void,
|
||||
onDoublePress: () => void,
|
||||
): () => void {
|
||||
let lastPress = 0
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
return () => {
|
||||
const now = Date.now()
|
||||
const timeSince = now - lastPress
|
||||
const isDouble =
|
||||
timeSince <= DOUBLE_PRESS_TIMEOUT_MS && timeout !== undefined
|
||||
|
||||
if (isDouble) {
|
||||
clearTimeout(timeout)
|
||||
timeout = undefined
|
||||
setPending(false)
|
||||
onDoublePress()
|
||||
} else {
|
||||
setPending(true)
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
setPending(false)
|
||||
timeout = undefined
|
||||
}, DOUBLE_PRESS_TIMEOUT_MS)
|
||||
}
|
||||
lastPress = now
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub that provides ExitState for Ctrl+C/D double-press UI.
|
||||
* In the standalone package, this uses useInput directly rather than the
|
||||
* keybinding system.
|
||||
*/
|
||||
export function useExitOnCtrlCDWithKeybindings(
|
||||
_onExit?: () => void,
|
||||
_onInterrupt?: () => boolean,
|
||||
isActive: boolean = true,
|
||||
): ExitState {
|
||||
const [exitState, setExitState] = useState<ExitState>({
|
||||
pending: false,
|
||||
keyName: null,
|
||||
})
|
||||
|
||||
const handleCtrlC = useDoublePress(
|
||||
(pending: boolean) =>
|
||||
setExitState({ pending, keyName: pending ? 'Ctrl-C' : null }),
|
||||
() => process.exit(0),
|
||||
)
|
||||
|
||||
const handleCtrlD = useDoublePress(
|
||||
(pending: boolean) =>
|
||||
setExitState({ pending, keyName: pending ? 'Ctrl-D' : null }),
|
||||
() => process.exit(0),
|
||||
)
|
||||
|
||||
const handleInput = useCallback(
|
||||
(_input: string, key: { ctrl?: boolean; name?: string }) => {
|
||||
if (!isActive) return
|
||||
if (key.ctrl && key.name === 'c') {
|
||||
handleCtrlC()
|
||||
} else if (key.ctrl && key.name === 'd') {
|
||||
handleCtrlD()
|
||||
}
|
||||
},
|
||||
[isActive, handleCtrlC, handleCtrlD],
|
||||
)
|
||||
|
||||
useInput(handleInput, { isActive })
|
||||
|
||||
return exitState
|
||||
}
|
||||
222
packages/@ant/ink/src/hooks/useSearchInput.ts
Normal file
222
packages/@ant/ink/src/hooks/useSearchInput.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Minimal stub of useSearchInput for the standalone @anthropic/ink package.
|
||||
*
|
||||
* Provides the same interface as the full implementation but without
|
||||
* kill-ring / yank support. Suitable for FuzzyPicker and other theme
|
||||
* components that need text input handling.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
|
||||
import useInput from './use-input.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
|
||||
type UseSearchInputOptions = {
|
||||
isActive: boolean
|
||||
onExit: () => void
|
||||
onCancel?: () => void
|
||||
onExitUp?: () => void
|
||||
columns?: number
|
||||
passthroughCtrlKeys?: string[]
|
||||
initialQuery?: string
|
||||
backspaceExitsOnEmpty?: boolean
|
||||
}
|
||||
|
||||
type UseSearchInputReturn = {
|
||||
query: string
|
||||
setQuery: (q: string) => void
|
||||
cursorOffset: number
|
||||
handleKeyDown: (e: KeyboardEvent) => void
|
||||
}
|
||||
|
||||
const UNHANDLED_SPECIAL_KEYS = new Set([
|
||||
'pageup',
|
||||
'pagedown',
|
||||
'insert',
|
||||
'wheelup',
|
||||
'wheeldown',
|
||||
'mouse',
|
||||
'f1',
|
||||
'f2',
|
||||
'f3',
|
||||
'f4',
|
||||
'f5',
|
||||
'f6',
|
||||
'f7',
|
||||
'f8',
|
||||
'f9',
|
||||
'f10',
|
||||
'f11',
|
||||
'f12',
|
||||
])
|
||||
|
||||
export function useSearchInput({
|
||||
isActive,
|
||||
onExit,
|
||||
onCancel,
|
||||
onExitUp,
|
||||
columns,
|
||||
initialQuery = '',
|
||||
backspaceExitsOnEmpty = true,
|
||||
}: UseSearchInputOptions): UseSearchInputReturn {
|
||||
const { columns: terminalColumns } = useTerminalSize()
|
||||
const _effectiveColumns = columns ?? terminalColumns
|
||||
const [query, setQueryState] = useState(initialQuery)
|
||||
const [cursorOffset, setCursorOffset] = useState(initialQuery.length)
|
||||
|
||||
const setQuery = useCallback((q: string) => {
|
||||
setQueryState(q)
|
||||
setCursorOffset(q.length)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (!isActive) return
|
||||
|
||||
if (e.key === 'return' || e.key === 'down') {
|
||||
e.preventDefault()
|
||||
onExit()
|
||||
return
|
||||
}
|
||||
if (e.key === 'up') {
|
||||
e.preventDefault()
|
||||
onExitUp?.()
|
||||
return
|
||||
}
|
||||
if (e.key === 'escape') {
|
||||
e.preventDefault()
|
||||
if (onCancel) {
|
||||
onCancel()
|
||||
} else if (query.length > 0) {
|
||||
setQueryState('')
|
||||
setCursorOffset(0)
|
||||
} else {
|
||||
onExit()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === 'backspace') {
|
||||
e.preventDefault()
|
||||
if (query.length === 0) {
|
||||
if (backspaceExitsOnEmpty) (onCancel ?? onExit)()
|
||||
return
|
||||
}
|
||||
const newOffset = Math.max(0, cursorOffset - 1)
|
||||
setQueryState(query.slice(0, newOffset) + query.slice(cursorOffset))
|
||||
setCursorOffset(newOffset)
|
||||
return
|
||||
}
|
||||
if (e.key === 'delete') {
|
||||
e.preventDefault()
|
||||
if (cursorOffset < query.length) {
|
||||
setQueryState(query.slice(0, cursorOffset) + query.slice(cursorOffset + 1))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === 'left') {
|
||||
e.preventDefault()
|
||||
setCursorOffset(Math.max(0, cursorOffset - 1))
|
||||
return
|
||||
}
|
||||
if (e.key === 'right') {
|
||||
e.preventDefault()
|
||||
setCursorOffset(Math.min(query.length, cursorOffset + 1))
|
||||
return
|
||||
}
|
||||
if (e.key === 'home') {
|
||||
e.preventDefault()
|
||||
setCursorOffset(0)
|
||||
return
|
||||
}
|
||||
if (e.key === 'end') {
|
||||
e.preventDefault()
|
||||
setCursorOffset(query.length)
|
||||
return
|
||||
}
|
||||
if (e.ctrl) {
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'a':
|
||||
e.preventDefault()
|
||||
setCursorOffset(0)
|
||||
return
|
||||
case 'e':
|
||||
e.preventDefault()
|
||||
setCursorOffset(query.length)
|
||||
return
|
||||
case 'b':
|
||||
e.preventDefault()
|
||||
setCursorOffset(Math.max(0, cursorOffset - 1))
|
||||
return
|
||||
case 'f':
|
||||
e.preventDefault()
|
||||
setCursorOffset(Math.min(query.length, cursorOffset + 1))
|
||||
return
|
||||
case 'd': {
|
||||
e.preventDefault()
|
||||
if (query.length === 0) {
|
||||
;(onCancel ?? onExit)()
|
||||
return
|
||||
}
|
||||
if (cursorOffset < query.length) {
|
||||
setQueryState(query.slice(0, cursorOffset) + query.slice(cursorOffset + 1))
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'h': {
|
||||
e.preventDefault()
|
||||
if (query.length === 0) {
|
||||
if (backspaceExitsOnEmpty) (onCancel ?? onExit)()
|
||||
return
|
||||
}
|
||||
const newOffset = Math.max(0, cursorOffset - 1)
|
||||
setQueryState(query.slice(0, newOffset) + query.slice(cursorOffset))
|
||||
setCursorOffset(newOffset)
|
||||
return
|
||||
}
|
||||
case 'c':
|
||||
e.preventDefault()
|
||||
onCancel?.()
|
||||
return
|
||||
case 'u':
|
||||
e.preventDefault()
|
||||
setQueryState(query.slice(cursorOffset))
|
||||
setCursorOffset(0)
|
||||
return
|
||||
case 'k':
|
||||
e.preventDefault()
|
||||
setQueryState(query.slice(0, cursorOffset))
|
||||
return
|
||||
case 'w': {
|
||||
e.preventDefault()
|
||||
// Delete word before cursor
|
||||
const before = query.slice(0, cursorOffset)
|
||||
const after = query.slice(cursorOffset)
|
||||
const trimmed = before.replace(/\S+\s*$/, '')
|
||||
setQueryState(trimmed + after)
|
||||
setCursorOffset(trimmed.length)
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === 'tab') {
|
||||
return
|
||||
}
|
||||
|
||||
// Regular character input
|
||||
if (e.key.length >= 1 && !UNHANDLED_SPECIAL_KEYS.has(e.key)) {
|
||||
e.preventDefault()
|
||||
setQueryState(query.slice(0, cursorOffset) + e.key + query.slice(cursorOffset))
|
||||
setCursorOffset(cursorOffset + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge: subscribe via useInput and adapt to KeyboardEvent
|
||||
useInput(
|
||||
(_input: string, _key: unknown, event: { keypress: string }) => {
|
||||
handleKeyDown(new KeyboardEvent(event.keypress))
|
||||
},
|
||||
{ isActive },
|
||||
)
|
||||
|
||||
return { query, setQuery, cursorOffset, handleKeyDown }
|
||||
}
|
||||
126
packages/@ant/ink/src/hooks/useTerminalNotification.ts
Normal file
126
packages/@ant/ink/src/hooks/useTerminalNotification.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { createContext, useCallback, useContext, useMemo } from 'react'
|
||||
import { isProgressReportingAvailable, type Progress } from '../core/terminal.js'
|
||||
import { BEL } from '../core/termio/ansi.js'
|
||||
import { ITERM2, OSC, osc, PROGRESS, wrapForMultiplexer } from '../core/termio/osc.js'
|
||||
|
||||
type WriteRaw = (data: string) => void
|
||||
|
||||
export const TerminalWriteContext = createContext<WriteRaw | null>(null)
|
||||
|
||||
export const TerminalWriteProvider = TerminalWriteContext.Provider
|
||||
|
||||
export type TerminalNotification = {
|
||||
notifyITerm2: (opts: { message: string; title?: string }) => void
|
||||
notifyKitty: (opts: { message: string; title: string; id: number }) => void
|
||||
notifyGhostty: (opts: { message: string; title: string }) => void
|
||||
notifyBell: () => void
|
||||
/**
|
||||
* Report progress to the terminal via OSC 9;4 sequences.
|
||||
* Supported terminals: ConEmu, Ghostty 1.2.0+, iTerm2 3.6.6+
|
||||
* Pass state=null to clear progress.
|
||||
*/
|
||||
progress: (state: Progress['state'] | null, percentage?: number) => void
|
||||
}
|
||||
|
||||
export function useTerminalNotification(): TerminalNotification {
|
||||
const writeRaw = useContext(TerminalWriteContext)
|
||||
if (!writeRaw) {
|
||||
throw new Error(
|
||||
'useTerminalNotification must be used within TerminalWriteProvider',
|
||||
)
|
||||
}
|
||||
|
||||
const notifyITerm2 = useCallback(
|
||||
({ message, title }: { message: string; title?: string }) => {
|
||||
const displayString = title ? `${title}:\n${message}` : message
|
||||
writeRaw(wrapForMultiplexer(osc(OSC.ITERM2, `\n\n${displayString}`)))
|
||||
},
|
||||
[writeRaw],
|
||||
)
|
||||
|
||||
const notifyKitty = useCallback(
|
||||
({
|
||||
message,
|
||||
title,
|
||||
id,
|
||||
}: {
|
||||
message: string
|
||||
title: string
|
||||
id: number
|
||||
}) => {
|
||||
writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=0:p=title`, title)))
|
||||
writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:p=body`, message)))
|
||||
writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=1:a=focus`, '')))
|
||||
},
|
||||
[writeRaw],
|
||||
)
|
||||
|
||||
const notifyGhostty = useCallback(
|
||||
({ message, title }: { message: string; title: string }) => {
|
||||
writeRaw(wrapForMultiplexer(osc(OSC.GHOSTTY, 'notify', title, message)))
|
||||
},
|
||||
[writeRaw],
|
||||
)
|
||||
|
||||
const notifyBell = useCallback(() => {
|
||||
// Raw BEL — inside tmux this triggers tmux's bell-action (window flag).
|
||||
// Wrapping would make it opaque DCS payload and lose that fallback.
|
||||
writeRaw(BEL)
|
||||
}, [writeRaw])
|
||||
|
||||
const progress = useCallback(
|
||||
(state: Progress['state'] | null, percentage?: number) => {
|
||||
if (!isProgressReportingAvailable()) {
|
||||
return
|
||||
}
|
||||
if (!state) {
|
||||
writeRaw(
|
||||
wrapForMultiplexer(
|
||||
osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.CLEAR, ''),
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
const pct = Math.max(0, Math.min(100, Math.round(percentage ?? 0)))
|
||||
switch (state) {
|
||||
case 'completed':
|
||||
writeRaw(
|
||||
wrapForMultiplexer(
|
||||
osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.CLEAR, ''),
|
||||
),
|
||||
)
|
||||
break
|
||||
case 'error':
|
||||
writeRaw(
|
||||
wrapForMultiplexer(
|
||||
osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.ERROR, pct),
|
||||
),
|
||||
)
|
||||
break
|
||||
case 'indeterminate':
|
||||
writeRaw(
|
||||
wrapForMultiplexer(
|
||||
osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.INDETERMINATE, ''),
|
||||
),
|
||||
)
|
||||
break
|
||||
case 'running':
|
||||
writeRaw(
|
||||
wrapForMultiplexer(
|
||||
osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.SET, pct),
|
||||
),
|
||||
)
|
||||
break
|
||||
case null:
|
||||
// Handled by the if guard above
|
||||
break
|
||||
}
|
||||
},
|
||||
[writeRaw],
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => ({ notifyITerm2, notifyKitty, notifyGhostty, notifyBell, progress }),
|
||||
[notifyITerm2, notifyKitty, notifyGhostty, notifyBell, progress],
|
||||
)
|
||||
}
|
||||
15
packages/@ant/ink/src/hooks/useTerminalSize.ts
Normal file
15
packages/@ant/ink/src/hooks/useTerminalSize.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useContext } from 'react'
|
||||
import {
|
||||
type TerminalSize,
|
||||
TerminalSizeContext,
|
||||
} from '../components/TerminalSizeContext.js'
|
||||
|
||||
export function useTerminalSize(): TerminalSize {
|
||||
const size = useContext(TerminalSizeContext)
|
||||
|
||||
if (!size) {
|
||||
throw new Error('useTerminalSize must be used within an Ink App component')
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
Reference in New Issue
Block a user