@vsuryav/agent-sim 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +25 -0
  2. package/bin/agent-sim.js +25 -0
  3. package/package.json +72 -0
  4. package/src/app-paths.ts +29 -0
  5. package/src/app-sync.test.ts +75 -0
  6. package/src/app-sync.ts +110 -0
  7. package/src/cli.ts +129 -0
  8. package/src/collector/claude-code.test.ts +102 -0
  9. package/src/collector/claude-code.ts +133 -0
  10. package/src/collector/codex-cli.test.ts +116 -0
  11. package/src/collector/codex-cli.ts +149 -0
  12. package/src/collector/db.test.ts +59 -0
  13. package/src/collector/db.ts +125 -0
  14. package/src/collector/names.test.ts +21 -0
  15. package/src/collector/names.ts +28 -0
  16. package/src/collector/personality.test.ts +40 -0
  17. package/src/collector/personality.ts +46 -0
  18. package/src/collector/remote-sync.test.ts +31 -0
  19. package/src/collector/remote-sync.ts +171 -0
  20. package/src/collector/sync.test.ts +67 -0
  21. package/src/collector/sync.ts +148 -0
  22. package/src/collector/types.ts +1 -0
  23. package/src/engine/bootstrap/state.ts +3 -0
  24. package/src/engine/buddy/CompanionSprite.tsx +371 -0
  25. package/src/engine/buddy/companion.ts +133 -0
  26. package/src/engine/buddy/prompt.ts +36 -0
  27. package/src/engine/buddy/sprites.ts +514 -0
  28. package/src/engine/buddy/types.ts +148 -0
  29. package/src/engine/buddy/useBuddyNotification.tsx +98 -0
  30. package/src/engine/ink/Ansi.tsx +292 -0
  31. package/src/engine/ink/bidi.ts +139 -0
  32. package/src/engine/ink/clearTerminal.ts +74 -0
  33. package/src/engine/ink/colorize.ts +231 -0
  34. package/src/engine/ink/components/AlternateScreen.tsx +80 -0
  35. package/src/engine/ink/components/App.tsx +658 -0
  36. package/src/engine/ink/components/AppContext.ts +21 -0
  37. package/src/engine/ink/components/Box.tsx +214 -0
  38. package/src/engine/ink/components/Button.tsx +192 -0
  39. package/src/engine/ink/components/ClockContext.tsx +112 -0
  40. package/src/engine/ink/components/CursorDeclarationContext.ts +32 -0
  41. package/src/engine/ink/components/ErrorOverview.tsx +109 -0
  42. package/src/engine/ink/components/Link.tsx +42 -0
  43. package/src/engine/ink/components/Newline.tsx +39 -0
  44. package/src/engine/ink/components/NoSelect.tsx +68 -0
  45. package/src/engine/ink/components/RawAnsi.tsx +57 -0
  46. package/src/engine/ink/components/ScrollBox.tsx +237 -0
  47. package/src/engine/ink/components/Spacer.tsx +20 -0
  48. package/src/engine/ink/components/StdinContext.ts +49 -0
  49. package/src/engine/ink/components/TerminalFocusContext.tsx +52 -0
  50. package/src/engine/ink/components/TerminalSizeContext.tsx +7 -0
  51. package/src/engine/ink/components/Text.tsx +254 -0
  52. package/src/engine/ink/constants.ts +2 -0
  53. package/src/engine/ink/dom.ts +484 -0
  54. package/src/engine/ink/events/click-event.ts +38 -0
  55. package/src/engine/ink/events/dispatcher.ts +233 -0
  56. package/src/engine/ink/events/emitter.ts +39 -0
  57. package/src/engine/ink/events/event-handlers.ts +73 -0
  58. package/src/engine/ink/events/event.ts +11 -0
  59. package/src/engine/ink/events/focus-event.ts +21 -0
  60. package/src/engine/ink/events/input-event.ts +205 -0
  61. package/src/engine/ink/events/keyboard-event.ts +51 -0
  62. package/src/engine/ink/events/terminal-event.ts +107 -0
  63. package/src/engine/ink/events/terminal-focus-event.ts +19 -0
  64. package/src/engine/ink/focus.ts +181 -0
  65. package/src/engine/ink/frame.ts +124 -0
  66. package/src/engine/ink/get-max-width.ts +27 -0
  67. package/src/engine/ink/global.d.ts +18 -0
  68. package/src/engine/ink/hit-test.ts +130 -0
  69. package/src/engine/ink/hooks/use-animation-frame.ts +57 -0
  70. package/src/engine/ink/hooks/use-app.ts +8 -0
  71. package/src/engine/ink/hooks/use-declared-cursor.ts +73 -0
  72. package/src/engine/ink/hooks/use-input.ts +92 -0
  73. package/src/engine/ink/hooks/use-interval.ts +67 -0
  74. package/src/engine/ink/hooks/use-search-highlight.ts +53 -0
  75. package/src/engine/ink/hooks/use-selection.ts +104 -0
  76. package/src/engine/ink/hooks/use-stdin.ts +8 -0
  77. package/src/engine/ink/hooks/use-tab-status.ts +72 -0
  78. package/src/engine/ink/hooks/use-terminal-focus.ts +16 -0
  79. package/src/engine/ink/hooks/use-terminal-title.ts +31 -0
  80. package/src/engine/ink/hooks/use-terminal-viewport.ts +96 -0
  81. package/src/engine/ink/ink.tsx +1723 -0
  82. package/src/engine/ink/instances.ts +10 -0
  83. package/src/engine/ink/layout/engine.ts +6 -0
  84. package/src/engine/ink/layout/geometry.ts +97 -0
  85. package/src/engine/ink/layout/node.ts +152 -0
  86. package/src/engine/ink/layout/yoga.ts +308 -0
  87. package/src/engine/ink/line-width-cache.ts +24 -0
  88. package/src/engine/ink/log-update.ts +773 -0
  89. package/src/engine/ink/measure-element.ts +23 -0
  90. package/src/engine/ink/measure-text.ts +47 -0
  91. package/src/engine/ink/node-cache.ts +54 -0
  92. package/src/engine/ink/optimizer.ts +93 -0
  93. package/src/engine/ink/output.ts +797 -0
  94. package/src/engine/ink/parse-keypress.ts +801 -0
  95. package/src/engine/ink/reconciler.ts +512 -0
  96. package/src/engine/ink/render-border.ts +231 -0
  97. package/src/engine/ink/render-node-to-output.ts +1462 -0
  98. package/src/engine/ink/render-to-screen.ts +231 -0
  99. package/src/engine/ink/renderer.ts +178 -0
  100. package/src/engine/ink/root.ts +184 -0
  101. package/src/engine/ink/screen.ts +1486 -0
  102. package/src/engine/ink/searchHighlight.ts +93 -0
  103. package/src/engine/ink/selection.ts +917 -0
  104. package/src/engine/ink/squash-text-nodes.ts +92 -0
  105. package/src/engine/ink/stringWidth.ts +222 -0
  106. package/src/engine/ink/styles.ts +771 -0
  107. package/src/engine/ink/supports-hyperlinks.ts +57 -0
  108. package/src/engine/ink/tabstops.ts +46 -0
  109. package/src/engine/ink/terminal-focus-state.ts +47 -0
  110. package/src/engine/ink/terminal-querier.ts +212 -0
  111. package/src/engine/ink/terminal.ts +248 -0
  112. package/src/engine/ink/termio/ansi.ts +75 -0
  113. package/src/engine/ink/termio/csi.ts +319 -0
  114. package/src/engine/ink/termio/dec.ts +60 -0
  115. package/src/engine/ink/termio/esc.ts +67 -0
  116. package/src/engine/ink/termio/osc.ts +493 -0
  117. package/src/engine/ink/termio/parser.ts +394 -0
  118. package/src/engine/ink/termio/sgr.ts +308 -0
  119. package/src/engine/ink/termio/tokenize.ts +319 -0
  120. package/src/engine/ink/termio/types.ts +236 -0
  121. package/src/engine/ink/useTerminalNotification.ts +126 -0
  122. package/src/engine/ink/warn.ts +9 -0
  123. package/src/engine/ink/widest-line.ts +19 -0
  124. package/src/engine/ink/wrap-text.ts +74 -0
  125. package/src/engine/ink/wrapAnsi.ts +20 -0
  126. package/src/engine/native-ts/yoga-layout/enums.ts +134 -0
  127. package/src/engine/native-ts/yoga-layout/index.ts +2578 -0
  128. package/src/engine/stubs/bootstrap-state.ts +4 -0
  129. package/src/engine/stubs/debug.ts +6 -0
  130. package/src/engine/stubs/log.ts +4 -0
  131. package/src/engine/utils/debug.ts +5 -0
  132. package/src/engine/utils/earlyInput.ts +4 -0
  133. package/src/engine/utils/env.ts +15 -0
  134. package/src/engine/utils/envUtils.ts +4 -0
  135. package/src/engine/utils/execFileNoThrow.ts +24 -0
  136. package/src/engine/utils/fullscreen.ts +4 -0
  137. package/src/engine/utils/intl.ts +9 -0
  138. package/src/engine/utils/log.ts +3 -0
  139. package/src/engine/utils/semver.ts +13 -0
  140. package/src/engine/utils/sliceAnsi.ts +10 -0
  141. package/src/engine/utils/theme.ts +17 -0
  142. package/src/game/App.tsx +141 -0
  143. package/src/game/agents/behavior.ts +249 -0
  144. package/src/game/agents/speech.ts +57 -0
  145. package/src/game/canvas.ts +98 -0
  146. package/src/game/launch.ts +36 -0
  147. package/src/game/ship/ShipView.tsx +145 -0
  148. package/src/game/ship/ship-map.ts +172 -0
  149. package/src/game/ui/AgentBio.tsx +72 -0
  150. package/src/game/ui/HUD.tsx +63 -0
  151. package/src/game/ui/StatusBar.tsx +49 -0
  152. package/src/game/useKeyboard.ts +62 -0
  153. package/src/main.tsx +22 -0
  154. package/src/run-interactive.ts +74 -0
@@ -0,0 +1,73 @@
1
+ import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
2
+ import CursorDeclarationContext from '../components/CursorDeclarationContext.js'
3
+ import type { DOMElement } from '../dom.js'
4
+
5
+ /**
6
+ * Declares where the terminal cursor should be parked after each frame.
7
+ *
8
+ * Terminal emulators render IME preedit text at the physical cursor
9
+ * position, and screen readers / screen magnifiers track the native
10
+ * cursor — so parking it at the text input's caret makes CJK input
11
+ * appear inline and lets accessibility tools follow the input.
12
+ *
13
+ * Returns a ref callback to attach to the Box that contains the input.
14
+ * The declared (line, column) is interpreted relative to that Box's
15
+ * nodeCache rect (populated by renderNodeToOutput).
16
+ *
17
+ * Timing: Both ref attach and useLayoutEffect fire in React's layout
18
+ * phase — after resetAfterCommit calls scheduleRender. scheduleRender
19
+ * defers onRender via queueMicrotask, so onRender runs AFTER layout
20
+ * effects commit and reads the fresh declaration on the first frame
21
+ * (no one-keystroke lag). Test env uses onImmediateRender (synchronous,
22
+ * no microtask), so tests compensate by calling ink.onRender()
23
+ * explicitly after render.
24
+ */
25
+ export function useDeclaredCursor({
26
+ line,
27
+ column,
28
+ active,
29
+ }: {
30
+ line: number
31
+ column: number
32
+ active: boolean
33
+ }): (element: DOMElement | null) => void {
34
+ const setCursorDeclaration = useContext(CursorDeclarationContext)
35
+ const nodeRef = useRef<DOMElement | null>(null)
36
+
37
+ const setNode = useCallback((node: DOMElement | null) => {
38
+ nodeRef.current = node
39
+ }, [])
40
+
41
+ // When active, set unconditionally. When inactive, clear conditionally
42
+ // (only if the currently-declared node is ours). The node-identity check
43
+ // handles two hazards:
44
+ // 1. A memo()ized active instance elsewhere (e.g. the search input in
45
+ // a memo'd Footer) doesn't re-render this commit — an inactive
46
+ // instance re-rendering here must not clobber it.
47
+ // 2. Sibling handoff (menu focus moving between list items) — when
48
+ // focus moves opposite to sibling order, the newly-inactive item's
49
+ // effect runs AFTER the newly-active item's set. Without the node
50
+ // check it would clobber.
51
+ // No dep array: must re-declare every commit so the active instance
52
+ // re-claims the declaration after another instance's unmount-cleanup or
53
+ // sibling handoff nulls it.
54
+ useLayoutEffect(() => {
55
+ const node = nodeRef.current
56
+ if (active && node) {
57
+ setCursorDeclaration({ relativeX: column, relativeY: line, node })
58
+ } else {
59
+ setCursorDeclaration(null, node)
60
+ }
61
+ })
62
+
63
+ // Clear on unmount (conditionally — another instance may own by then).
64
+ // Separate effect with empty deps so cleanup only fires once — not on
65
+ // every line/column change, which would transiently null between commits.
66
+ useLayoutEffect(() => {
67
+ return () => {
68
+ setCursorDeclaration(null, nodeRef.current)
69
+ }
70
+ }, [setCursorDeclaration])
71
+
72
+ return setNode
73
+ }
@@ -0,0 +1,92 @@
1
+ import { useEffect, useLayoutEffect } from 'react'
2
+ import { useEventCallback } from 'usehooks-ts'
3
+ import type { InputEvent, Key } from '../events/input-event.js'
4
+ import useStdin from './use-stdin.js'
5
+
6
+ type Handler = (input: string, key: Key, event: InputEvent) => void
7
+
8
+ type Options = {
9
+ /**
10
+ * Enable or disable capturing of user input.
11
+ * Useful when there are multiple useInput hooks used at once to avoid handling the same input several times.
12
+ *
13
+ * @default true
14
+ */
15
+ isActive?: boolean
16
+ }
17
+
18
+ /**
19
+ * This hook is used for handling user input.
20
+ * It's a more convenient alternative to using `StdinContext` and listening to `data` events.
21
+ * The callback you pass to `useInput` is called for each character when user enters any input.
22
+ * 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`.
23
+ *
24
+ * ```
25
+ * import {useInput} from 'ink';
26
+ *
27
+ * const UserInput = () => {
28
+ * useInput((input, key) => {
29
+ * if (input === 'q') {
30
+ * // Exit program
31
+ * }
32
+ *
33
+ * if (key.leftArrow) {
34
+ * // Left arrow key pressed
35
+ * }
36
+ * });
37
+ *
38
+ * return …
39
+ * };
40
+ * ```
41
+ */
42
+ const useInput = (inputHandler: Handler, options: Options = {}) => {
43
+ const { setRawMode, internal_exitOnCtrlC, internal_eventEmitter } = useStdin()
44
+
45
+ // useLayoutEffect (not useEffect) so that raw mode is enabled synchronously
46
+ // during React's commit phase, before render() returns. With useEffect, raw
47
+ // mode setup is deferred to the next event loop tick via React's scheduler,
48
+ // leaving the terminal in cooked mode — keystrokes echo and the cursor is
49
+ // visible until the effect fires.
50
+ useLayoutEffect(() => {
51
+ if (options.isActive === false) {
52
+ return
53
+ }
54
+
55
+ setRawMode(true)
56
+
57
+ return () => {
58
+ setRawMode(false)
59
+ }
60
+ }, [options.isActive, setRawMode])
61
+
62
+ // Register the listener once on mount so its slot in the EventEmitter's
63
+ // listener array is stable. If isActive were in the effect's deps, the
64
+ // listener would re-append on false→true, moving it behind listeners
65
+ // that registered while it was inactive — breaking
66
+ // stopImmediatePropagation() ordering. useEventCallback keeps the
67
+ // reference stable while reading latest isActive/inputHandler from
68
+ // closure (it syncs via useLayoutEffect, so it's compiler-safe).
69
+ const handleData = useEventCallback((event: InputEvent) => {
70
+ if (options.isActive === false) {
71
+ return
72
+ }
73
+ const { input, key } = event
74
+
75
+ // If app is not supposed to exit on Ctrl+C, then let input listener handle it
76
+ // Note: discreteUpdates is called at the App level when emitting events,
77
+ // so all listeners are already within a high-priority update context.
78
+ if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) {
79
+ inputHandler(input, key, event)
80
+ }
81
+ })
82
+
83
+ useEffect(() => {
84
+ internal_eventEmitter?.on('input', handleData)
85
+
86
+ return () => {
87
+ internal_eventEmitter?.removeListener('input', handleData)
88
+ }
89
+ }, [internal_eventEmitter, handleData])
90
+ }
91
+
92
+ export default useInput
@@ -0,0 +1,67 @@
1
+ import { useContext, useEffect, useRef, useState } from 'react'
2
+ import { ClockContext } from '../components/ClockContext.js'
3
+
4
+ /**
5
+ * Returns the clock time, updating at the given interval.
6
+ * Subscribes as non-keepAlive — won't keep the clock alive on its own,
7
+ * but updates whenever a keepAlive subscriber (e.g. the spinner)
8
+ * is driving the clock.
9
+ *
10
+ * Use this to drive pure time-based computations (shimmer position,
11
+ * frame index) from the shared clock.
12
+ */
13
+ export function useAnimationTimer(intervalMs: number): number {
14
+ const clock = useContext(ClockContext)
15
+ const [time, setTime] = useState(() => clock?.now() ?? 0)
16
+
17
+ useEffect(() => {
18
+ if (!clock) return
19
+
20
+ let lastUpdate = clock.now()
21
+
22
+ const onChange = (): void => {
23
+ const now = clock.now()
24
+ if (now - lastUpdate >= intervalMs) {
25
+ lastUpdate = now
26
+ setTime(now)
27
+ }
28
+ }
29
+
30
+ return clock.subscribe(onChange, false)
31
+ }, [clock, intervalMs])
32
+
33
+ return time
34
+ }
35
+
36
+ /**
37
+ * Interval hook backed by the shared Clock.
38
+ *
39
+ * Unlike `useInterval` from `usehooks-ts` (which creates its own setInterval),
40
+ * this piggybacks on the single shared clock so all timers consolidate into
41
+ * one wake-up. Pass `null` for intervalMs to pause.
42
+ */
43
+ export function useInterval(
44
+ callback: () => void,
45
+ intervalMs: number | null,
46
+ ): void {
47
+ const callbackRef = useRef(callback)
48
+ callbackRef.current = callback
49
+
50
+ const clock = useContext(ClockContext)
51
+
52
+ useEffect(() => {
53
+ if (!clock || intervalMs === null) return
54
+
55
+ let lastUpdate = clock.now()
56
+
57
+ const onChange = (): void => {
58
+ const now = clock.now()
59
+ if (now - lastUpdate >= intervalMs) {
60
+ lastUpdate = now
61
+ callbackRef.current()
62
+ }
63
+ }
64
+
65
+ return clock.subscribe(onChange, false)
66
+ }, [clock, intervalMs])
67
+ }
@@ -0,0 +1,53 @@
1
+ import { useContext, useMemo } from 'react'
2
+ import StdinContext from '../components/StdinContext.js'
3
+ import type { DOMElement } from '../dom.js'
4
+ import instances from '../instances.js'
5
+ import type { MatchPosition } from '../render-to-screen.js'
6
+
7
+ /**
8
+ * Set the search highlight query on the Ink instance. Non-empty → all
9
+ * visible occurrences are inverted on the next frame (SGR 7, screen-buffer
10
+ * overlay, same damage machinery as selection). Empty → clears.
11
+ *
12
+ * This is a screen-space highlight — it matches the RENDERED text, not the
13
+ * source message text. Works for anything visible (bash output, file paths,
14
+ * error messages) regardless of where it came from in the message tree. A
15
+ * query that matched in source but got truncated/ellipsized in rendering
16
+ * won't highlight; that's acceptable — we highlight what you see.
17
+ */
18
+ export function useSearchHighlight(): {
19
+ setQuery: (query: string) => void
20
+ /** Paint an existing DOM subtree (from the MAIN tree) to a fresh
21
+ * Screen at its natural height, scan. Element-relative positions
22
+ * (row 0 = element top). Zero context duplication — the element
23
+ * IS the one built with all real providers. */
24
+ scanElement: (el: DOMElement) => MatchPosition[]
25
+ /** Position-based CURRENT highlight. Every frame writes yellow at
26
+ * positions[currentIdx] + rowOffset. The scan-highlight (inverse on
27
+ * all matches) still runs — this overlays on top. rowOffset tracks
28
+ * scroll; positions stay stable (message-relative). null clears. */
29
+ setPositions: (
30
+ state: {
31
+ positions: MatchPosition[]
32
+ rowOffset: number
33
+ currentIdx: number
34
+ } | null,
35
+ ) => void
36
+ } {
37
+ useContext(StdinContext) // anchor to App subtree for hook rules
38
+ const ink = instances.get(process.stdout)
39
+ return useMemo(() => {
40
+ if (!ink) {
41
+ return {
42
+ setQuery: () => {},
43
+ scanElement: () => [],
44
+ setPositions: () => {},
45
+ }
46
+ }
47
+ return {
48
+ setQuery: (query: string) => ink.setSearchHighlight(query),
49
+ scanElement: (el: DOMElement) => ink.scanElementSubtree(el),
50
+ setPositions: state => ink.setSearchPositions(state),
51
+ }
52
+ }, [ink])
53
+ }
@@ -0,0 +1,104 @@
1
+ import { useContext, useMemo, useSyncExternalStore } from 'react'
2
+ import StdinContext from '../components/StdinContext.js'
3
+ import instances from '../instances.js'
4
+ import {
5
+ type FocusMove,
6
+ type SelectionState,
7
+ shiftAnchor,
8
+ } from '../selection.js'
9
+
10
+ /**
11
+ * Access to text selection operations on the Ink instance (fullscreen only).
12
+ * Returns no-op functions when fullscreen mode is disabled.
13
+ */
14
+ export function useSelection(): {
15
+ copySelection: () => string
16
+ /** Copy without clearing the highlight (for copy-on-select). */
17
+ copySelectionNoClear: () => string
18
+ clearSelection: () => void
19
+ hasSelection: () => boolean
20
+ /** Read the raw mutable selection state (for drag-to-scroll). */
21
+ getState: () => SelectionState | null
22
+ /** Subscribe to selection mutations (start/update/finish/clear). */
23
+ subscribe: (cb: () => void) => () => void
24
+ /** Shift the anchor row by dRow, clamped to [minRow, maxRow]. */
25
+ shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
26
+ /** Shift anchor AND focus by dRow (keyboard scroll: whole selection
27
+ * tracks content). Clamped points get col reset to the full-width edge
28
+ * since their content was captured by captureScrolledRows. Reads
29
+ * screen.width from the ink instance for the col-reset boundary. */
30
+ shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
31
+ /** Keyboard selection extension (shift+arrow): move focus, anchor fixed.
32
+ * Left/right wrap across rows; up/down clamp at viewport edges. */
33
+ moveFocus: (move: FocusMove) => void
34
+ /** Capture text from rows about to scroll out of the viewport (call
35
+ * BEFORE scrollBy so the screen buffer still has the outgoing rows). */
36
+ captureScrolledRows: (
37
+ firstRow: number,
38
+ lastRow: number,
39
+ side: 'above' | 'below',
40
+ ) => void
41
+ /** Set the selection highlight bg color (theme-piping; solid bg
42
+ * replaces the old SGR-7 inverse so syntax highlighting stays readable
43
+ * under selection). Call once on mount + whenever theme changes. */
44
+ setSelectionBgColor: (color: string) => void
45
+ } {
46
+ // Look up the Ink instance via stdout — same pattern as instances map.
47
+ // StdinContext is available (it's always provided), and the Ink instance
48
+ // is keyed by stdout which we can get from process.stdout since there's
49
+ // only one Ink instance per process in practice.
50
+ useContext(StdinContext) // anchor to App subtree for hook rules
51
+ const ink = instances.get(process.stdout)
52
+ // Memoize so callers can safely use the return value in dependency arrays.
53
+ // ink is a singleton per stdout — stable across renders.
54
+ return useMemo(() => {
55
+ if (!ink) {
56
+ return {
57
+ copySelection: () => '',
58
+ copySelectionNoClear: () => '',
59
+ clearSelection: () => {},
60
+ hasSelection: () => false,
61
+ getState: () => null,
62
+ subscribe: () => () => {},
63
+ shiftAnchor: () => {},
64
+ shiftSelection: () => {},
65
+ moveFocus: () => {},
66
+ captureScrolledRows: () => {},
67
+ setSelectionBgColor: () => {},
68
+ }
69
+ }
70
+ return {
71
+ copySelection: () => ink.copySelection(),
72
+ copySelectionNoClear: () => ink.copySelectionNoClear(),
73
+ clearSelection: () => ink.clearTextSelection(),
74
+ hasSelection: () => ink.hasTextSelection(),
75
+ getState: () => ink.selection,
76
+ subscribe: (cb: () => void) => ink.subscribeToSelectionChange(cb),
77
+ shiftAnchor: (dRow: number, minRow: number, maxRow: number) =>
78
+ shiftAnchor(ink.selection, dRow, minRow, maxRow),
79
+ shiftSelection: (dRow, minRow, maxRow) =>
80
+ ink.shiftSelectionForScroll(dRow, minRow, maxRow),
81
+ moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move),
82
+ captureScrolledRows: (firstRow, lastRow, side) =>
83
+ ink.captureScrolledRows(firstRow, lastRow, side),
84
+ setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color),
85
+ }
86
+ }, [ink])
87
+ }
88
+
89
+ const NO_SUBSCRIBE = () => () => {}
90
+ const ALWAYS_FALSE = () => false
91
+
92
+ /**
93
+ * Reactive selection-exists state. Re-renders the caller when a text
94
+ * selection is created or cleared. Always returns false outside
95
+ * fullscreen mode (selection is only available in alt-screen).
96
+ */
97
+ export function useHasSelection(): boolean {
98
+ useContext(StdinContext)
99
+ const ink = instances.get(process.stdout)
100
+ return useSyncExternalStore(
101
+ ink ? ink.subscribeToSelectionChange : NO_SUBSCRIBE,
102
+ ink ? ink.hasTextSelection : ALWAYS_FALSE,
103
+ )
104
+ }
@@ -0,0 +1,8 @@
1
+ import { useContext } from 'react'
2
+ import StdinContext from '../components/StdinContext.js'
3
+
4
+ /**
5
+ * `useStdin` is a React hook, which exposes stdin stream.
6
+ */
7
+ const useStdin = () => useContext(StdinContext)
8
+ export default useStdin
@@ -0,0 +1,72 @@
1
+ import { useContext, useEffect, useRef } from 'react'
2
+ import {
3
+ CLEAR_TAB_STATUS,
4
+ supportsTabStatus,
5
+ tabStatus,
6
+ wrapForMultiplexer,
7
+ } from '../termio/osc.js'
8
+ import type { Color } from '../termio/types.js'
9
+ import { TerminalWriteContext } from '../useTerminalNotification.js'
10
+
11
+ export type TabStatusKind = 'idle' | 'busy' | 'waiting'
12
+
13
+ const rgb = (r: number, g: number, b: number): Color => ({
14
+ type: 'rgb',
15
+ r,
16
+ g,
17
+ b,
18
+ })
19
+
20
+ // Per the OSC 21337 usage guide's suggested mapping.
21
+ const TAB_STATUS_PRESETS: Record<
22
+ TabStatusKind,
23
+ { indicator: Color; status: string; statusColor: Color }
24
+ > = {
25
+ idle: {
26
+ indicator: rgb(0, 215, 95),
27
+ status: 'Idle',
28
+ statusColor: rgb(136, 136, 136),
29
+ },
30
+ busy: {
31
+ indicator: rgb(255, 149, 0),
32
+ status: 'Working…',
33
+ statusColor: rgb(255, 149, 0),
34
+ },
35
+ waiting: {
36
+ indicator: rgb(95, 135, 255),
37
+ status: 'Waiting',
38
+ statusColor: rgb(95, 135, 255),
39
+ },
40
+ }
41
+
42
+ /**
43
+ * Declaratively set the tab-status indicator (OSC 21337).
44
+ *
45
+ * Emits a colored dot + short status text to the tab sidebar. Terminals
46
+ * that don't support OSC 21337 discard the sequence silently, so this is
47
+ * safe to call unconditionally. Wrapped for tmux/screen passthrough.
48
+ *
49
+ * Pass `null` to opt out. If a status was previously set, transitioning to
50
+ * `null` emits CLEAR_TAB_STATUS so toggling off mid-session doesn't leave
51
+ * a stale dot. Process-exit cleanup is handled by ink.tsx's unmount path.
52
+ */
53
+ export function useTabStatus(kind: TabStatusKind | null): void {
54
+ const writeRaw = useContext(TerminalWriteContext)
55
+ const prevKindRef = useRef<TabStatusKind | null>(null)
56
+
57
+ useEffect(() => {
58
+ // When kind transitions from non-null to null (e.g. user toggles off
59
+ // showStatusInTerminalTab mid-session), clear the stale dot.
60
+ if (kind === null) {
61
+ if (prevKindRef.current !== null && writeRaw && supportsTabStatus()) {
62
+ writeRaw(wrapForMultiplexer(CLEAR_TAB_STATUS))
63
+ }
64
+ prevKindRef.current = null
65
+ return
66
+ }
67
+
68
+ prevKindRef.current = kind
69
+ if (!writeRaw || !supportsTabStatus()) return
70
+ writeRaw(wrapForMultiplexer(tabStatus(TAB_STATUS_PRESETS[kind])))
71
+ }, [kind, writeRaw])
72
+ }
@@ -0,0 +1,16 @@
1
+ import { useContext } from 'react'
2
+ import TerminalFocusContext from '../components/TerminalFocusContext.js'
3
+
4
+ /**
5
+ * Hook to check if the terminal has focus.
6
+ *
7
+ * Uses DECSET 1004 focus reporting - the terminal sends escape sequences
8
+ * when it gains or loses focus. These are handled automatically
9
+ * by Ink and filtered from useInput.
10
+ *
11
+ * @returns true if the terminal is focused (or focus state is unknown)
12
+ */
13
+ export function useTerminalFocus(): boolean {
14
+ const { isTerminalFocused } = useContext(TerminalFocusContext)
15
+ return isTerminalFocused
16
+ }
@@ -0,0 +1,31 @@
1
+ import { useContext, useEffect } from 'react'
2
+ import stripAnsi from 'strip-ansi'
3
+ import { OSC, osc } from '../termio/osc.js'
4
+ import { TerminalWriteContext } from '../useTerminalNotification.js'
5
+
6
+ /**
7
+ * Declaratively set the terminal tab/window title.
8
+ *
9
+ * Pass a string to set the title. ANSI escape sequences are stripped
10
+ * automatically so callers don't need to know about terminal encoding.
11
+ * Pass `null` to opt out — the hook becomes a no-op and leaves the
12
+ * terminal title untouched.
13
+ *
14
+ * On Windows, uses `process.title` (classic conhost doesn't support OSC).
15
+ * Elsewhere, writes OSC 0 (set title+icon) via Ink's stdout.
16
+ */
17
+ export function useTerminalTitle(title: string | null): void {
18
+ const writeRaw = useContext(TerminalWriteContext)
19
+
20
+ useEffect(() => {
21
+ if (title === null || !writeRaw) return
22
+
23
+ const clean = stripAnsi(title)
24
+
25
+ if (process.platform === 'win32') {
26
+ process.title = clean
27
+ } else {
28
+ writeRaw(osc(OSC.SET_TITLE_AND_ICON, clean))
29
+ }
30
+ }, [title, writeRaw])
31
+ }
@@ -0,0 +1,96 @@
1
+ import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
2
+ import { TerminalSizeContext } from '../components/TerminalSizeContext.js'
3
+ import type { DOMElement } from '../dom.js'
4
+
5
+ type ViewportEntry = {
6
+ /**
7
+ * Whether the element is currently within the terminal viewport
8
+ */
9
+ isVisible: boolean
10
+ }
11
+
12
+ /**
13
+ * Hook to detect if a component is within the terminal viewport.
14
+ *
15
+ * Returns a callback ref and a viewport entry object.
16
+ * Attach the ref to the component you want to track.
17
+ *
18
+ * The entry is updated during the layout phase (useLayoutEffect) so callers
19
+ * always read fresh values during render. Visibility changes do NOT trigger
20
+ * re-renders on their own — callers that re-render for other reasons (e.g.
21
+ * animation ticks, state changes) will pick up the latest value naturally.
22
+ * This avoids infinite update loops when combined with other layout effects
23
+ * that also call setState.
24
+ *
25
+ * @example
26
+ * const [ref, entry] = useTerminalViewport()
27
+ * return <Box ref={ref}><Animation enabled={entry.isVisible}>...</Animation></Box>
28
+ */
29
+ export function useTerminalViewport(): [
30
+ ref: (element: DOMElement | null) => void,
31
+ entry: ViewportEntry,
32
+ ] {
33
+ const terminalSize = useContext(TerminalSizeContext)
34
+ const elementRef = useRef<DOMElement | null>(null)
35
+ const entryRef = useRef<ViewportEntry>({ isVisible: true })
36
+
37
+ const setElement = useCallback((el: DOMElement | null) => {
38
+ elementRef.current = el
39
+ }, [])
40
+
41
+ // Runs on every render because yoga layout values can change
42
+ // without React being aware. Only updates the ref — no setState
43
+ // to avoid cascading re-renders during the commit phase.
44
+ // Walks the DOM ancestor chain fresh each time to avoid holding stale
45
+ // references after yoga tree rebuilds.
46
+ useLayoutEffect(() => {
47
+ const element = elementRef.current
48
+ if (!element?.yogaNode || !terminalSize) {
49
+ return
50
+ }
51
+
52
+ const height = element.yogaNode.getComputedHeight()
53
+ const rows = terminalSize.rows
54
+
55
+ // Walk the DOM parent chain (not yoga.getParent()) so we can detect
56
+ // scroll containers and subtract their scrollTop. Yoga computes layout
57
+ // positions without scroll offset — scrollTop is applied at render time.
58
+ // Without this, an element inside a ScrollBox whose yoga position exceeds
59
+ // terminalRows would be considered offscreen even when scrolled into view
60
+ // (e.g., the spinner in fullscreen mode after enough messages accumulate).
61
+ let absoluteTop = element.yogaNode.getComputedTop()
62
+ let parent: DOMElement | undefined = element.parentNode
63
+ let root = element.yogaNode
64
+ while (parent) {
65
+ if (parent.yogaNode) {
66
+ absoluteTop += parent.yogaNode.getComputedTop()
67
+ root = parent.yogaNode
68
+ }
69
+ // scrollTop is only ever set on scroll containers (by ScrollBox + renderer).
70
+ // Non-scroll nodes have undefined scrollTop → falsy fast-path.
71
+ if (parent.scrollTop) absoluteTop -= parent.scrollTop
72
+ parent = parent.parentNode
73
+ }
74
+
75
+ // Only the root's height matters
76
+ const screenHeight = root.getComputedHeight()
77
+
78
+ const bottom = absoluteTop + height
79
+ // When content overflows the viewport (screenHeight > rows), the
80
+ // cursor-restore at frame end scrolls one extra row into scrollback.
81
+ // log-update.ts accounts for this with scrollbackRows = viewportY + 1.
82
+ // We must match, otherwise an element at the boundary is considered
83
+ // "visible" here (animation keeps ticking) but its row is treated as
84
+ // scrollback by log-update (content change → full reset → flicker).
85
+ const cursorRestoreScroll = screenHeight > rows ? 1 : 0
86
+ const viewportY = Math.max(0, screenHeight - rows) + cursorRestoreScroll
87
+ const viewportBottom = viewportY + rows
88
+ const visible = bottom > viewportY && absoluteTop < viewportBottom
89
+
90
+ if (visible !== entryRef.current.isVisible) {
91
+ entryRef.current = { isVisible: visible }
92
+ }
93
+ })
94
+
95
+ return [setElement, entryRef.current]
96
+ }