@silvery/term 0.3.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.
- package/package.json +54 -0
- package/src/adapters/canvas-adapter.ts +356 -0
- package/src/adapters/dom-adapter.ts +452 -0
- package/src/adapters/flexily-zero-adapter.ts +368 -0
- package/src/adapters/terminal-adapter.ts +305 -0
- package/src/adapters/yoga-adapter.ts +370 -0
- package/src/ansi/ansi.ts +251 -0
- package/src/ansi/constants.ts +76 -0
- package/src/ansi/detection.ts +441 -0
- package/src/ansi/hyperlink.ts +38 -0
- package/src/ansi/index.ts +201 -0
- package/src/ansi/patch-console.ts +159 -0
- package/src/ansi/sgr-codes.ts +34 -0
- package/src/ansi/storybook.ts +209 -0
- package/src/ansi/term.ts +724 -0
- package/src/ansi/types.ts +202 -0
- package/src/ansi/underline.ts +156 -0
- package/src/ansi/utils.ts +65 -0
- package/src/ansi-sanitize.ts +509 -0
- package/src/app.ts +571 -0
- package/src/bound-term.ts +94 -0
- package/src/bracketed-paste.ts +75 -0
- package/src/browser-renderer.ts +174 -0
- package/src/buffer.ts +1984 -0
- package/src/clipboard.ts +74 -0
- package/src/cursor-query.ts +85 -0
- package/src/device-attrs.ts +228 -0
- package/src/devtools.ts +123 -0
- package/src/dom/index.ts +194 -0
- package/src/errors.ts +39 -0
- package/src/focus-reporting.ts +48 -0
- package/src/hit-registry-core.ts +228 -0
- package/src/hit-registry.ts +176 -0
- package/src/index.ts +458 -0
- package/src/input.ts +119 -0
- package/src/inspector.ts +155 -0
- package/src/kitty-detect.ts +95 -0
- package/src/kitty-manager.ts +160 -0
- package/src/layout-engine.ts +296 -0
- package/src/layout.ts +26 -0
- package/src/measurer.ts +74 -0
- package/src/mode-query.ts +106 -0
- package/src/mouse-events.ts +419 -0
- package/src/mouse.ts +83 -0
- package/src/non-tty.ts +223 -0
- package/src/osc-markers.ts +32 -0
- package/src/osc-palette.ts +169 -0
- package/src/output.ts +406 -0
- package/src/pane-manager.ts +248 -0
- package/src/pipeline/CLAUDE.md +587 -0
- package/src/pipeline/content-phase-adapter.ts +976 -0
- package/src/pipeline/content-phase.ts +1765 -0
- package/src/pipeline/helpers.ts +42 -0
- package/src/pipeline/index.ts +416 -0
- package/src/pipeline/layout-phase.ts +686 -0
- package/src/pipeline/measure-phase.ts +198 -0
- package/src/pipeline/measure-stats.ts +21 -0
- package/src/pipeline/output-phase.ts +2593 -0
- package/src/pipeline/render-box.ts +343 -0
- package/src/pipeline/render-helpers.ts +243 -0
- package/src/pipeline/render-text.ts +1255 -0
- package/src/pipeline/types.ts +161 -0
- package/src/pipeline.ts +29 -0
- package/src/pixel-size.ts +119 -0
- package/src/render-adapter.ts +179 -0
- package/src/renderer.ts +1330 -0
- package/src/runtime/create-app.tsx +1845 -0
- package/src/runtime/create-buffer.ts +18 -0
- package/src/runtime/create-runtime.ts +325 -0
- package/src/runtime/diff.ts +56 -0
- package/src/runtime/event-handlers.ts +254 -0
- package/src/runtime/index.ts +119 -0
- package/src/runtime/keys.ts +8 -0
- package/src/runtime/layout.ts +164 -0
- package/src/runtime/run.tsx +318 -0
- package/src/runtime/term-provider.ts +399 -0
- package/src/runtime/terminal-lifecycle.ts +246 -0
- package/src/runtime/tick.ts +219 -0
- package/src/runtime/types.ts +210 -0
- package/src/scheduler.ts +723 -0
- package/src/screenshot.ts +57 -0
- package/src/scroll-region.ts +69 -0
- package/src/scroll-utils.ts +97 -0
- package/src/term-def.ts +267 -0
- package/src/terminal-caps.ts +5 -0
- package/src/terminal-colors.ts +216 -0
- package/src/termtest.ts +224 -0
- package/src/text-sizing.ts +109 -0
- package/src/toolbelt/index.ts +72 -0
- package/src/unicode.ts +1763 -0
- package/src/xterm/index.ts +491 -0
- package/src/xterm/xterm-provider.ts +204 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* silvery-loop Runtime Module
|
|
3
|
+
*
|
|
4
|
+
* Provides the core primitives for the silvery-loop architecture:
|
|
5
|
+
*
|
|
6
|
+
* Layer 0: Pure render functions
|
|
7
|
+
* - layout() - React element → Buffer
|
|
8
|
+
* - diff() - Buffer diff → ANSI patch
|
|
9
|
+
*
|
|
10
|
+
* Layer 1: Runtime kernel (createRuntime)
|
|
11
|
+
* - events() - AsyncIterable event stream
|
|
12
|
+
* - schedule() - Effect scheduling
|
|
13
|
+
* - render() - Output to target
|
|
14
|
+
*
|
|
15
|
+
* Stream helpers
|
|
16
|
+
* - merge, map, filter, takeUntil, etc.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Types
|
|
20
|
+
export type {
|
|
21
|
+
Buffer,
|
|
22
|
+
Dims,
|
|
23
|
+
Event,
|
|
24
|
+
RenderTarget,
|
|
25
|
+
Runtime,
|
|
26
|
+
RuntimeOptions,
|
|
27
|
+
// Provider types
|
|
28
|
+
Provider,
|
|
29
|
+
ProviderEvent,
|
|
30
|
+
NamespacedEvent,
|
|
31
|
+
ProviderEventKey,
|
|
32
|
+
EventData,
|
|
33
|
+
} from "./types"
|
|
34
|
+
|
|
35
|
+
// Terminal provider
|
|
36
|
+
export {
|
|
37
|
+
createTermProvider,
|
|
38
|
+
type TermProvider,
|
|
39
|
+
type TermState,
|
|
40
|
+
type TermEvents,
|
|
41
|
+
type TermProviderOptions,
|
|
42
|
+
} from "./term-provider"
|
|
43
|
+
|
|
44
|
+
// Layer 0: Pure render functions
|
|
45
|
+
export { layout, layoutSync, ensureLayoutEngine, type LayoutOptions } from "./layout"
|
|
46
|
+
export { diff, render, type DiffMode } from "./diff"
|
|
47
|
+
|
|
48
|
+
// Buffer helper
|
|
49
|
+
export { createBuffer } from "./create-buffer"
|
|
50
|
+
|
|
51
|
+
// Layer 1: Runtime kernel
|
|
52
|
+
export { createRuntime } from "./create-runtime"
|
|
53
|
+
|
|
54
|
+
// Layer 2: React integration
|
|
55
|
+
export {
|
|
56
|
+
run,
|
|
57
|
+
useInput,
|
|
58
|
+
useExit,
|
|
59
|
+
usePaste,
|
|
60
|
+
type RunOptions,
|
|
61
|
+
type RunHandle,
|
|
62
|
+
type InputHandler,
|
|
63
|
+
type PasteHandler,
|
|
64
|
+
type Key,
|
|
65
|
+
} from "./run"
|
|
66
|
+
|
|
67
|
+
// Key parsing utilities
|
|
68
|
+
export { parseKey, emptyKey } from "./keys"
|
|
69
|
+
|
|
70
|
+
// Terminal lifecycle (suspend/resume, interrupt)
|
|
71
|
+
export {
|
|
72
|
+
captureTerminalState,
|
|
73
|
+
restoreTerminalState,
|
|
74
|
+
resumeTerminalState,
|
|
75
|
+
performSuspend,
|
|
76
|
+
CTRL_C,
|
|
77
|
+
CTRL_Z,
|
|
78
|
+
type TerminalLifecycleOptions,
|
|
79
|
+
type TerminalState,
|
|
80
|
+
} from "./terminal-lifecycle"
|
|
81
|
+
|
|
82
|
+
// Layer 1.5: TEA store (re-exported for discoverability)
|
|
83
|
+
export { createStore, silveryUpdate, defaultInit, withFocusManagement } from "@silvery/tea/store"
|
|
84
|
+
export type { StoreConfig, StoreApi } from "@silvery/tea/store"
|
|
85
|
+
|
|
86
|
+
// Layer 3: Store integration
|
|
87
|
+
export {
|
|
88
|
+
createApp,
|
|
89
|
+
useApp,
|
|
90
|
+
useAppShallow,
|
|
91
|
+
StoreContext,
|
|
92
|
+
type AppDefinition,
|
|
93
|
+
type AppHandle,
|
|
94
|
+
type AppRunOptions,
|
|
95
|
+
type AppRunner,
|
|
96
|
+
type EventHandler,
|
|
97
|
+
type EventHandlers,
|
|
98
|
+
type EventHandlerContext,
|
|
99
|
+
} from "./create-app"
|
|
100
|
+
|
|
101
|
+
// Time/tick sources
|
|
102
|
+
export { createTick, createFrameTick, createSecondTick, createAdaptiveTick } from "./tick"
|
|
103
|
+
|
|
104
|
+
// Stream helpers (re-export from streams module)
|
|
105
|
+
export {
|
|
106
|
+
merge,
|
|
107
|
+
map,
|
|
108
|
+
filter,
|
|
109
|
+
filterMap,
|
|
110
|
+
takeUntil,
|
|
111
|
+
take,
|
|
112
|
+
throttle,
|
|
113
|
+
debounce,
|
|
114
|
+
batch,
|
|
115
|
+
concat,
|
|
116
|
+
zip,
|
|
117
|
+
fromArray,
|
|
118
|
+
fromArrayWithDelay,
|
|
119
|
+
} from "@silvery/tea/streams"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key parsing for silvery-loop runtime.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports from canonical source in ../keys.ts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type { Key, InputHandler, ParsedKeypress } from "@silvery/tea/keys"
|
|
8
|
+
export { parseKey, emptyKey, parseKeypress, splitRawInput } from "@silvery/tea/keys"
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure layout function for silvery-loop.
|
|
3
|
+
*
|
|
4
|
+
* Takes a React element and dimensions, returns an immutable Buffer.
|
|
5
|
+
* This is Layer 0 - no runtime, no events, just pure rendering.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createTerm } from "../ansi/index"
|
|
9
|
+
import React, { type ReactElement } from "react"
|
|
10
|
+
import { bufferToStyledText, bufferToText } from "../buffer"
|
|
11
|
+
import { StdoutContext, StderrContext, TermContext } from "@silvery/react/context"
|
|
12
|
+
import { ensureDefaultLayoutEngine, isLayoutEngineInitialized } from "../layout-engine"
|
|
13
|
+
import { executeRender } from "../pipeline"
|
|
14
|
+
import { createContainer, createFiberRoot, getContainerRoot, reconciler } from "@silvery/react/reconciler"
|
|
15
|
+
import type { Buffer, Dims } from "./types"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Options for the layout function.
|
|
19
|
+
*/
|
|
20
|
+
export interface LayoutOptions {
|
|
21
|
+
/** Skip layout notifications (for static renders). Default: true */
|
|
22
|
+
skipLayoutNotifications?: boolean
|
|
23
|
+
/** Strip ANSI codes for plain text output. Default: false */
|
|
24
|
+
plain?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Ensure layout engine is initialized.
|
|
29
|
+
* Must be called before layout() in async contexts.
|
|
30
|
+
*/
|
|
31
|
+
export async function ensureLayoutEngine(): Promise<void> {
|
|
32
|
+
if (!isLayoutEngineInitialized()) {
|
|
33
|
+
await ensureDefaultLayoutEngine()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pure layout function - renders a React element to a Buffer.
|
|
39
|
+
*
|
|
40
|
+
* IMPORTANT: Call ensureLayoutEngine() first in async contexts.
|
|
41
|
+
* The layout engine must be initialized before calling this.
|
|
42
|
+
*
|
|
43
|
+
* @param element React element to render
|
|
44
|
+
* @param dims Terminal dimensions
|
|
45
|
+
* @param options Layout options
|
|
46
|
+
* @returns Immutable Buffer with text, ansi, and nodes
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```typescript
|
|
50
|
+
* import { layout, ensureLayoutEngine } from '@silvery/term/runtime'
|
|
51
|
+
*
|
|
52
|
+
* await ensureLayoutEngine()
|
|
53
|
+
* const buffer = layout(<Text>Hello</Text>, { cols: 80, rows: 24 })
|
|
54
|
+
* console.log(buffer.text) // "Hello"
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export function layout(element: ReactElement, dims: Dims, options: LayoutOptions = {}): Buffer {
|
|
58
|
+
if (!isLayoutEngineInitialized()) {
|
|
59
|
+
throw new Error("Layout engine not initialized. Call ensureLayoutEngine() first.")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { skipLayoutNotifications = true, plain = false } = options
|
|
63
|
+
const { cols: width, rows: height } = dims
|
|
64
|
+
|
|
65
|
+
// Create container for React reconciliation
|
|
66
|
+
const container = createContainer(() => {})
|
|
67
|
+
|
|
68
|
+
// Create fiber root
|
|
69
|
+
const fiberRoot = createFiberRoot(container)
|
|
70
|
+
|
|
71
|
+
// Create minimal mock stdout for components that use useStdout
|
|
72
|
+
const mockStdout = {
|
|
73
|
+
columns: width,
|
|
74
|
+
rows: height,
|
|
75
|
+
write: () => true,
|
|
76
|
+
isTTY: false,
|
|
77
|
+
on: () => mockStdout,
|
|
78
|
+
off: () => mockStdout,
|
|
79
|
+
once: () => mockStdout,
|
|
80
|
+
removeListener: () => mockStdout,
|
|
81
|
+
addListener: () => mockStdout,
|
|
82
|
+
} as unknown as NodeJS.WriteStream
|
|
83
|
+
|
|
84
|
+
// Create mock term for components that use useTerm()
|
|
85
|
+
const mockTerm = createTerm({ color: plain ? null : "truecolor" })
|
|
86
|
+
|
|
87
|
+
// Wrap with minimal contexts (no input handling needed)
|
|
88
|
+
const wrapped = React.createElement(
|
|
89
|
+
TermContext.Provider,
|
|
90
|
+
{ value: mockTerm },
|
|
91
|
+
React.createElement(
|
|
92
|
+
StdoutContext.Provider,
|
|
93
|
+
{
|
|
94
|
+
value: {
|
|
95
|
+
stdout: mockStdout,
|
|
96
|
+
write: () => {},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
React.createElement(
|
|
100
|
+
StderrContext.Provider,
|
|
101
|
+
{
|
|
102
|
+
value: {
|
|
103
|
+
stderr: process.stderr,
|
|
104
|
+
write: (data: string) => {
|
|
105
|
+
process.stderr.write(data)
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
element,
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
// Mount, render, and unmount - all without act warnings
|
|
115
|
+
withoutActWarnings(() => {
|
|
116
|
+
reconciler.updateContainerSync(wrapped, fiberRoot, null, null)
|
|
117
|
+
reconciler.flushSyncWork()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// Execute render pipeline (skip layout notifications for static renders)
|
|
121
|
+
const root = getContainerRoot(container)
|
|
122
|
+
const { buffer: termBuffer } = executeRender(root, width, height, null, {
|
|
123
|
+
skipLayoutNotifications,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// Get text representations
|
|
127
|
+
const text = bufferToText(termBuffer)
|
|
128
|
+
const ansi = bufferToStyledText(termBuffer)
|
|
129
|
+
|
|
130
|
+
// Unmount (cleanup)
|
|
131
|
+
withoutActWarnings(() => {
|
|
132
|
+
reconciler.updateContainerSync(null, fiberRoot, null, null)
|
|
133
|
+
reconciler.flushSyncWork()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
text,
|
|
138
|
+
ansi,
|
|
139
|
+
nodes: root,
|
|
140
|
+
_buffer: termBuffer,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Synchronous layout - assumes engine is already initialized.
|
|
146
|
+
* Throws if engine not ready.
|
|
147
|
+
*/
|
|
148
|
+
export function layoutSync(element: ReactElement, dims: Dims, options: LayoutOptions = {}): Buffer {
|
|
149
|
+
return layout(element, dims, options)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Run a function with React act warnings disabled.
|
|
154
|
+
* Used for static renders where we don't use act() and don't need layout feedback.
|
|
155
|
+
*/
|
|
156
|
+
function withoutActWarnings(fn: () => void): void {
|
|
157
|
+
const prev = (globalThis as any).IS_REACT_ACT_ENVIRONMENT
|
|
158
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = false
|
|
159
|
+
try {
|
|
160
|
+
fn()
|
|
161
|
+
} finally {
|
|
162
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = prev
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run() - Layer 2 entry point for silvery-loop
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper over createApp() for simple React apps with keyboard input.
|
|
5
|
+
* Use this when you want React component state (useState, useEffect)
|
|
6
|
+
* with simple keyboard input via useInput().
|
|
7
|
+
*
|
|
8
|
+
* For stores and providers, use createApp() (Layer 3) directly.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```tsx
|
|
12
|
+
* import { run, useInput } from '@silvery/term/runtime'
|
|
13
|
+
*
|
|
14
|
+
* function Counter() {
|
|
15
|
+
* const [count, setCount] = useState(0)
|
|
16
|
+
*
|
|
17
|
+
* useInput((input, key) => {
|
|
18
|
+
* if (input === 'j') setCount(c => c + 1)
|
|
19
|
+
* if (key.upArrow) setCount(c => c + 1)
|
|
20
|
+
* if (input === 'q') return 'exit'
|
|
21
|
+
* })
|
|
22
|
+
*
|
|
23
|
+
* return <Text>Count: {count}</Text>
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* await run(<Counter />)
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import React, { useContext, useEffect, useRef, type ReactElement } from "react"
|
|
31
|
+
|
|
32
|
+
import { RuntimeContext } from "@silvery/react/context"
|
|
33
|
+
import { createApp } from "./create-app"
|
|
34
|
+
import type { Key, InputHandler } from "./keys"
|
|
35
|
+
import type { Term } from "../ansi/term"
|
|
36
|
+
import { detectTerminalCaps } from "../terminal-caps"
|
|
37
|
+
import { detectTheme } from "@silvery/theme/detect"
|
|
38
|
+
import { ThemeProvider } from "@silvery/theme/ThemeContext"
|
|
39
|
+
|
|
40
|
+
// Re-export types from keys.ts
|
|
41
|
+
export type { Key, InputHandler } from "./keys"
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Types
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Options for run().
|
|
49
|
+
*
|
|
50
|
+
* run() auto-detects terminal capabilities and enables features by default.
|
|
51
|
+
* Pass explicit values to override. For the full list of capabilities detected,
|
|
52
|
+
* see {@link detectTerminalCaps} in terminal-caps.ts.
|
|
53
|
+
*
|
|
54
|
+
* **Mouse tracking note:** When `mouse` is enabled (the default), the terminal
|
|
55
|
+
* captures mouse events and native text selection (copy/paste) requires holding
|
|
56
|
+
* Shift (or Option on macOS in some terminals). Set `mouse: false` to restore
|
|
57
|
+
* native copy/paste behavior.
|
|
58
|
+
*/
|
|
59
|
+
export interface RunOptions {
|
|
60
|
+
/** Terminal dimensions (default: from process.stdout) */
|
|
61
|
+
cols?: number
|
|
62
|
+
rows?: number
|
|
63
|
+
/** Standard output (default: process.stdout) */
|
|
64
|
+
stdout?: NodeJS.WriteStream
|
|
65
|
+
/** Standard input (default: process.stdin) */
|
|
66
|
+
stdin?: NodeJS.ReadStream
|
|
67
|
+
/**
|
|
68
|
+
* Plain writable sink for ANSI output. Headless mode with active output.
|
|
69
|
+
* Requires cols and rows. Input via handle.press().
|
|
70
|
+
*/
|
|
71
|
+
writable?: { write(data: string): void }
|
|
72
|
+
/** Abort signal for external cleanup */
|
|
73
|
+
signal?: AbortSignal
|
|
74
|
+
/**
|
|
75
|
+
* Enable Kitty keyboard protocol for unambiguous key identification
|
|
76
|
+
* (Cmd ⌘, Hyper ✦ modifiers, key release events).
|
|
77
|
+
* - `true`: enable with DISAMBIGUATE flag (1)
|
|
78
|
+
* - number: enable with specific KittyFlags bitfield
|
|
79
|
+
* - `false`: don't enable
|
|
80
|
+
* - Default: auto-detected from terminal (enabled for Ghostty, Kitty, WezTerm, foot)
|
|
81
|
+
*/
|
|
82
|
+
kitty?: boolean | number
|
|
83
|
+
/**
|
|
84
|
+
* Enable SGR mouse tracking (mode 1006) for click, scroll, and drag events.
|
|
85
|
+
* When enabled, native text selection requires holding Shift (or Option on macOS)
|
|
86
|
+
* and native terminal scrolling is disabled.
|
|
87
|
+
* Default: `true` in fullscreen mode, `false` in inline mode (where content
|
|
88
|
+
* lives in terminal scrollback and natural scrolling is expected).
|
|
89
|
+
*/
|
|
90
|
+
mouse?: boolean
|
|
91
|
+
/**
|
|
92
|
+
* Render mode: fullscreen (alt screen, default) or inline (scrollback-compatible).
|
|
93
|
+
*/
|
|
94
|
+
mode?: "fullscreen" | "inline"
|
|
95
|
+
/**
|
|
96
|
+
* Enable Kitty text sizing protocol (OSC 66) for PUA characters.
|
|
97
|
+
* Ensures nerdfont/powerline icons are measured and rendered at the correct width.
|
|
98
|
+
* - `true`: force enable
|
|
99
|
+
* - `"auto"`: enable if terminal supports it (Kitty 0.40+, Ghostty)
|
|
100
|
+
* - `false`: disabled
|
|
101
|
+
* - Default: "auto"
|
|
102
|
+
*/
|
|
103
|
+
textSizing?: boolean | "auto"
|
|
104
|
+
/**
|
|
105
|
+
* Enable terminal focus reporting (CSI ?1004h).
|
|
106
|
+
* Dispatches 'term:focus' events with `{ focused: boolean }`.
|
|
107
|
+
* Default: true
|
|
108
|
+
*/
|
|
109
|
+
focusReporting?: boolean
|
|
110
|
+
/**
|
|
111
|
+
* Terminal capabilities for width measurement and output suppression.
|
|
112
|
+
* Default: auto-detected via detectTerminalCaps()
|
|
113
|
+
*/
|
|
114
|
+
caps?: import("../terminal-caps.js").TerminalCaps
|
|
115
|
+
/**
|
|
116
|
+
* Handle Ctrl+Z by suspending the process. Default: true
|
|
117
|
+
*/
|
|
118
|
+
suspendOnCtrlZ?: boolean
|
|
119
|
+
/**
|
|
120
|
+
* Handle Ctrl+C by restoring terminal and exiting. Default: true
|
|
121
|
+
*/
|
|
122
|
+
exitOnCtrlC?: boolean
|
|
123
|
+
/** Called before suspend. Return false to prevent. */
|
|
124
|
+
onSuspend?: () => boolean | void
|
|
125
|
+
/** Called after resume from suspend. */
|
|
126
|
+
onResume?: () => void
|
|
127
|
+
/** Called on Ctrl+C. Return false to prevent exit. */
|
|
128
|
+
onInterrupt?: () => boolean | void
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Handle returned by run() for controlling the app.
|
|
133
|
+
*/
|
|
134
|
+
export interface RunHandle {
|
|
135
|
+
/** Current rendered text (no ANSI) */
|
|
136
|
+
readonly text: string
|
|
137
|
+
/** Wait until the app exits */
|
|
138
|
+
waitUntilExit(): Promise<void>
|
|
139
|
+
/** Unmount and cleanup */
|
|
140
|
+
unmount(): void
|
|
141
|
+
/** Dispose (alias for unmount) — enables `using` */
|
|
142
|
+
[Symbol.dispose](): void
|
|
143
|
+
/** Send a key press */
|
|
144
|
+
press(key: string): Promise<void>
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Paste handler callback type */
|
|
148
|
+
export type PasteHandler = (text: string) => void
|
|
149
|
+
|
|
150
|
+
// ============================================================================
|
|
151
|
+
// Hooks (Layer 2 — uses RuntimeContext, works in both run() and createApp())
|
|
152
|
+
// ============================================================================
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Hook for handling keyboard input.
|
|
156
|
+
*
|
|
157
|
+
* Layer 2 variant: supports returning 'exit' from the handler to exit the app.
|
|
158
|
+
* For the standard hook (isActive, onPaste options), import from 'silvery'.
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* ```tsx
|
|
162
|
+
* useInput((input, key) => {
|
|
163
|
+
* if (input === 'q') return 'exit'
|
|
164
|
+
* if (key.upArrow) moveCursor(-1)
|
|
165
|
+
* if (key.downArrow) moveCursor(1)
|
|
166
|
+
* })
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
export function useInput(handler: InputHandler): void {
|
|
170
|
+
const rt = useContext(RuntimeContext)
|
|
171
|
+
|
|
172
|
+
// Stable ref for the handler — avoids tearing down/recreating the
|
|
173
|
+
// subscription on every render. Without this, rapid keystrokes between
|
|
174
|
+
// effect cleanup and setup are lost (e.g., Ctrl+D twice, Escape).
|
|
175
|
+
const handlerRef = useRef(handler)
|
|
176
|
+
handlerRef.current = handler
|
|
177
|
+
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (!rt) return
|
|
180
|
+
return rt.on("input", (input: string, key: Key) => {
|
|
181
|
+
const result = handlerRef.current(input, key)
|
|
182
|
+
if (result === "exit") rt.exit()
|
|
183
|
+
})
|
|
184
|
+
}, [rt])
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Hook for programmatic exit.
|
|
189
|
+
*/
|
|
190
|
+
export function useExit(): () => void {
|
|
191
|
+
const rt = useContext(RuntimeContext)
|
|
192
|
+
if (!rt) throw new Error("useExit must be used within run() or createApp()")
|
|
193
|
+
return rt.exit
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Hook for handling bracketed paste events.
|
|
198
|
+
*/
|
|
199
|
+
export function usePaste(handler: PasteHandler): void {
|
|
200
|
+
const rt = useContext(RuntimeContext)
|
|
201
|
+
|
|
202
|
+
// Stable ref — same pattern as useInput to avoid lost paste events.
|
|
203
|
+
const handlerRef = useRef(handler)
|
|
204
|
+
handlerRef.current = handler
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!rt) return
|
|
208
|
+
return rt.on("paste", (text: string) => {
|
|
209
|
+
handlerRef.current(text)
|
|
210
|
+
})
|
|
211
|
+
}, [rt])
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ============================================================================
|
|
215
|
+
// run() — thin wrapper over createApp()
|
|
216
|
+
// ============================================================================
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Run a React component with the silvery-loop runtime.
|
|
220
|
+
*
|
|
221
|
+
* Accepts either a Term instance or RunOptions:
|
|
222
|
+
* - `run(<App />, term)` — Term handles streams, createApp handles rendering
|
|
223
|
+
* - `run(<App />, { cols, rows, ... })` — classic options API
|
|
224
|
+
*
|
|
225
|
+
* Internally delegates to createApp() with an empty store.
|
|
226
|
+
* For stores and providers, use createApp() directly.
|
|
227
|
+
*/
|
|
228
|
+
export async function run(element: ReactElement, term: Term): Promise<RunHandle>
|
|
229
|
+
export async function run(element: ReactElement, options?: RunOptions): Promise<RunHandle>
|
|
230
|
+
export async function run(element: ReactElement, optionsOrTerm: RunOptions | Term = {}): Promise<RunHandle> {
|
|
231
|
+
// Term path: pass Term as provider + its streams, auto-enable from Term caps
|
|
232
|
+
if (isTerm(optionsOrTerm)) {
|
|
233
|
+
const term = optionsOrTerm as Term
|
|
234
|
+
const emulator = (term as Record<string, unknown>)._emulator as { feed(data: string): void } | undefined
|
|
235
|
+
|
|
236
|
+
// Emulator-backed term: headless mode with writable routing to emulator
|
|
237
|
+
if (emulator) {
|
|
238
|
+
const app = createApp(() => () => ({}))
|
|
239
|
+
const handle = await app.run(element, {
|
|
240
|
+
writable: { write: (s: string) => emulator.feed(s) },
|
|
241
|
+
cols: term.cols ?? 80,
|
|
242
|
+
rows: term.rows ?? 24,
|
|
243
|
+
// Wire resize: term.subscribe() fires when term.resize() is called
|
|
244
|
+
onResize: (handler) => term.subscribe((state) => handler(state)),
|
|
245
|
+
})
|
|
246
|
+
return wrapHandle(handle)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Real terminal: full setup
|
|
250
|
+
const caps = term.caps ?? detectTerminalCaps()
|
|
251
|
+
// Detect terminal colors via OSC — must happen before alt screen
|
|
252
|
+
const theme = await detectTheme()
|
|
253
|
+
const themed = <ThemeProvider theme={theme}>{element}</ThemeProvider>
|
|
254
|
+
const app = createApp(() => () => ({}))
|
|
255
|
+
const handle = await app.run(themed, {
|
|
256
|
+
term,
|
|
257
|
+
stdout: term.stdout,
|
|
258
|
+
stdin: term.stdin,
|
|
259
|
+
cols: term.cols ?? undefined,
|
|
260
|
+
rows: term.rows ?? undefined,
|
|
261
|
+
caps,
|
|
262
|
+
alternateScreen: true,
|
|
263
|
+
kitty: caps.kittyKeyboard,
|
|
264
|
+
mouse: true,
|
|
265
|
+
focusReporting: true,
|
|
266
|
+
textSizing: "auto",
|
|
267
|
+
})
|
|
268
|
+
return wrapHandle(handle)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Options path: auto-detect caps and derive defaults
|
|
272
|
+
const { mode, ...rest } = optionsOrTerm as RunOptions
|
|
273
|
+
const caps = rest.caps ?? detectTerminalCaps()
|
|
274
|
+
const headless = rest.writable != null || (rest.cols != null && rest.rows != null && !rest.stdout)
|
|
275
|
+
// Detect terminal colors via OSC — must happen before alt screen (skipped for headless)
|
|
276
|
+
const themed = headless
|
|
277
|
+
? element
|
|
278
|
+
: await detectTheme().then((theme) => <ThemeProvider theme={theme}>{element}</ThemeProvider>)
|
|
279
|
+
const app = createApp(() => () => ({}))
|
|
280
|
+
const handle = await app.run(themed, {
|
|
281
|
+
...rest,
|
|
282
|
+
caps,
|
|
283
|
+
alternateScreen: mode !== "inline",
|
|
284
|
+
kitty: rest.kitty ?? caps.kittyKeyboard,
|
|
285
|
+
mouse: rest.mouse ?? mode !== "inline",
|
|
286
|
+
focusReporting: rest.focusReporting ?? mode !== "inline",
|
|
287
|
+
textSizing: rest.textSizing ?? "auto",
|
|
288
|
+
})
|
|
289
|
+
return wrapHandle(handle)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Duck-type check: Term has getState and events as functions.
|
|
293
|
+
* Note: Term is a Proxy wrapping chalk, so typeof is "function" not "object". */
|
|
294
|
+
function isTerm(obj: unknown): obj is Term {
|
|
295
|
+
if (obj == null) return false
|
|
296
|
+
if (typeof obj !== "object" && typeof obj !== "function") return false
|
|
297
|
+
const o = obj as Record<string, unknown>
|
|
298
|
+
return typeof o.getState === "function" && typeof o.events === "function"
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Wrap AppHandle as RunHandle (subset of the full handle). */
|
|
302
|
+
function wrapHandle(handle: {
|
|
303
|
+
readonly text: string
|
|
304
|
+
waitUntilExit(): Promise<void>
|
|
305
|
+
unmount(): void
|
|
306
|
+
[Symbol.dispose](): void
|
|
307
|
+
press(key: string): Promise<void>
|
|
308
|
+
}): RunHandle {
|
|
309
|
+
return {
|
|
310
|
+
get text() {
|
|
311
|
+
return handle.text
|
|
312
|
+
},
|
|
313
|
+
waitUntilExit: () => handle.waitUntilExit(),
|
|
314
|
+
unmount: () => handle.unmount(),
|
|
315
|
+
[Symbol.dispose]: () => handle[Symbol.dispose](),
|
|
316
|
+
press: (key: string) => handle.press(key),
|
|
317
|
+
}
|
|
318
|
+
}
|