@silvery/tea 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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * withTerminal(process, opts?) — Plugin: ALL terminal I/O
3
+ *
4
+ * This plugin represents the terminal I/O layer in silvery's plugin
5
+ * composition model. It wraps all terminal concerns:
6
+ * - stdin → typed events (term:key, term:mouse, term:paste)
7
+ * - stdout → alternate screen, raw mode, incremental diff output
8
+ * - SIGWINCH → term:resize
9
+ * - Lifecycle (Ctrl+Z suspend/resume, Ctrl+C exit)
10
+ * - Protocols (SGR mouse, Kitty keyboard, bracketed paste)
11
+ *
12
+ * In the current architecture, terminal I/O is handled by createApp()
13
+ * and the TermProvider. This plugin provides the declarative interface
14
+ * for pipe() composition:
15
+ *
16
+ * ```tsx
17
+ * const app = pipe(
18
+ * createApp(store),
19
+ * withReact(<Board />),
20
+ * withTerminal(process, { mouse: true, kitty: true }),
21
+ * withFocus(),
22
+ * withDomEvents(),
23
+ * )
24
+ * ```
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * import { pipe, withTerminal } from '@silvery/tea'
29
+ *
30
+ * // All protocols enabled by default
31
+ * const app = pipe(baseApp, withTerminal(process))
32
+ *
33
+ * // Customize terminal options
34
+ * const app = pipe(baseApp, withTerminal(process, {
35
+ * mouse: true,
36
+ * kitty: true,
37
+ * paste: true,
38
+ * onSuspend: () => saveState(),
39
+ * onResume: () => restoreState(),
40
+ * }))
41
+ * ```
42
+ */
43
+
44
+ // =============================================================================
45
+ // Types
46
+ // =============================================================================
47
+
48
+ /**
49
+ * Process-like object that provides stdin/stdout streams.
50
+ * Accepts the Node.js global `process` or a mock.
51
+ */
52
+ export interface ProcessLike {
53
+ stdin: NodeJS.ReadStream
54
+ stdout: NodeJS.WriteStream
55
+ [key: string]: unknown
56
+ }
57
+
58
+ /**
59
+ * Options for withTerminal.
60
+ */
61
+ export interface WithTerminalOptions {
62
+ /**
63
+ * Enable SGR mouse tracking.
64
+ * Default: true
65
+ */
66
+ mouse?: boolean
67
+
68
+ /**
69
+ * Enable Kitty keyboard protocol.
70
+ * - `true`: auto-detect and enable with DISAMBIGUATE flag
71
+ * - number: enable with specific KittyFlags bitfield
72
+ * - `false`: don't enable
73
+ * Default: true
74
+ */
75
+ kitty?: boolean | number
76
+
77
+ /**
78
+ * Enable bracketed paste mode.
79
+ * Default: true
80
+ */
81
+ paste?: boolean
82
+
83
+ /**
84
+ * Enter alternate screen buffer.
85
+ * Default: true
86
+ */
87
+ alternateScreen?: boolean
88
+
89
+ /**
90
+ * Handle Ctrl+Z by suspending the process.
91
+ * Default: true
92
+ */
93
+ suspendOnCtrlZ?: boolean
94
+
95
+ /**
96
+ * Handle Ctrl+C by restoring terminal and exiting.
97
+ * Default: true
98
+ */
99
+ exitOnCtrlC?: boolean
100
+
101
+ /** Called before suspend. Return false to prevent. */
102
+ onSuspend?: () => boolean | void
103
+
104
+ /** Called after resume from suspend. */
105
+ onResume?: () => void
106
+
107
+ /** Called on Ctrl+C. Return false to prevent exit. */
108
+ onInterrupt?: () => boolean | void
109
+
110
+ /**
111
+ * Enable Kitty text sizing protocol for PUA characters.
112
+ * Default: false
113
+ */
114
+ textSizing?: boolean | "auto"
115
+
116
+ /**
117
+ * Enable terminal focus reporting.
118
+ * Default: false
119
+ */
120
+ focusReporting?: boolean
121
+ }
122
+
123
+ /**
124
+ * App enhanced with terminal configuration.
125
+ */
126
+ export interface AppWithTerminal {
127
+ /** The terminal options for this app */
128
+ readonly terminalOptions: WithTerminalOptions & { proc: ProcessLike }
129
+ }
130
+
131
+ /**
132
+ * Minimal app shape that withTerminal can enhance.
133
+ */
134
+ interface RunnableApp {
135
+ run(...args: unknown[]): unknown
136
+ [key: string]: unknown
137
+ }
138
+
139
+ // =============================================================================
140
+ // Implementation
141
+ // =============================================================================
142
+
143
+ /**
144
+ * Configure terminal I/O for an app.
145
+ *
146
+ * In pipe() composition, this captures the process streams and options
147
+ * so that run() configures terminal I/O correctly.
148
+ *
149
+ * The plugin wraps `run()` to inject terminal options:
150
+ * - stdin/stdout from the process object
151
+ * - Protocol options (mouse, kitty, paste)
152
+ * - Lifecycle handlers (suspend, resume, interrupt)
153
+ *
154
+ * @param proc - Process object with stdin/stdout (typically `process`)
155
+ * @param options - Terminal configuration
156
+ * @returns Plugin function that binds terminal config to the app
157
+ */
158
+ export function withTerminal<T extends RunnableApp>(
159
+ proc: ProcessLike,
160
+ options: WithTerminalOptions = {},
161
+ ): (app: T) => T & AppWithTerminal {
162
+ const termConfig = {
163
+ mouse: options.mouse ?? true,
164
+ kitty: options.kitty ?? true,
165
+ paste: options.paste ?? true,
166
+ alternateScreen: options.alternateScreen ?? true,
167
+ suspendOnCtrlZ: options.suspendOnCtrlZ ?? true,
168
+ exitOnCtrlC: options.exitOnCtrlC ?? true,
169
+ ...options,
170
+ proc,
171
+ }
172
+
173
+ return (app: T): T & AppWithTerminal => {
174
+ const originalRun = app.run
175
+
176
+ return Object.assign(Object.create(app), {
177
+ terminalOptions: termConfig,
178
+ run(...args: unknown[]) {
179
+ // Inject terminal options into the run call
180
+ // The first arg after element is typically options
181
+ const runOptions: Record<string, unknown> = {}
182
+
183
+ // Find or create options argument
184
+ let existingOptions: Record<string, unknown> | undefined
185
+ if (args.length > 0 && typeof args[args.length - 1] === "object" && args[args.length - 1] !== null) {
186
+ existingOptions = args[args.length - 1] as Record<string, unknown>
187
+ // Don't treat React elements as options
188
+ if ("type" in existingOptions && "props" in existingOptions) {
189
+ existingOptions = undefined
190
+ }
191
+ }
192
+
193
+ // Merge terminal options
194
+ Object.assign(runOptions, existingOptions ?? {}, {
195
+ stdin: proc.stdin,
196
+ stdout: proc.stdout,
197
+ mouse: termConfig.mouse,
198
+ kitty: termConfig.kitty,
199
+ alternateScreen: termConfig.alternateScreen,
200
+ suspendOnCtrlZ: termConfig.suspendOnCtrlZ,
201
+ exitOnCtrlC: termConfig.exitOnCtrlC,
202
+ textSizing: termConfig.textSizing,
203
+ focusReporting: termConfig.focusReporting,
204
+ onSuspend: termConfig.onSuspend,
205
+ onResume: termConfig.onResume,
206
+ onInterrupt: termConfig.onInterrupt,
207
+ })
208
+
209
+ // Replace options in args
210
+ if (existingOptions) {
211
+ const newArgs = [...args]
212
+ newArgs[newArgs.length - 1] = runOptions
213
+ return originalRun.apply(app, newArgs)
214
+ }
215
+ return originalRun.call(app, ...args, runOptions)
216
+ },
217
+ }) as T & AppWithTerminal
218
+ }
219
+ }