silvery 0.3.0 → 0.4.1

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 (120) hide show
  1. package/README.md +41 -145
  2. package/dist/chalk.js +3 -0
  3. package/dist/chalk.js.map +11 -0
  4. package/dist/index.js +340 -0
  5. package/dist/index.js.map +282 -0
  6. package/dist/ink.js +129 -0
  7. package/dist/ink.js.map +140 -0
  8. package/dist/runtime.js +394 -0
  9. package/dist/runtime.js.map +286 -0
  10. package/dist/theme.js +343 -0
  11. package/dist/theme.js.map +286 -0
  12. package/dist/ui/animation.js +3 -0
  13. package/dist/ui/animation.js.map +15 -0
  14. package/dist/ui/ansi.js +3 -0
  15. package/dist/ui/ansi.js.map +10 -0
  16. package/dist/ui/cli.js +8 -0
  17. package/dist/ui/cli.js.map +14 -0
  18. package/dist/ui/display.js +4 -0
  19. package/dist/ui/display.js.map +10 -0
  20. package/dist/ui/image.js +4 -0
  21. package/dist/ui/image.js.map +15 -0
  22. package/dist/ui/input.js +3 -0
  23. package/dist/ui/input.js.map +11 -0
  24. package/dist/ui/progress.js +8 -0
  25. package/dist/ui/progress.js.map +20 -0
  26. package/dist/ui/react.js +3 -0
  27. package/dist/ui/react.js.map +15 -0
  28. package/dist/ui/utils.js +3 -0
  29. package/dist/ui/utils.js.map +10 -0
  30. package/dist/ui/wrappers.js +14 -0
  31. package/dist/ui/wrappers.js.map +19 -0
  32. package/dist/ui.js +17 -0
  33. package/dist/ui.js.map +20 -0
  34. package/package.json +67 -15
  35. package/src/index.ts +67 -1
  36. package/src/runtime.ts +4 -0
  37. package/src/theme.ts +4 -0
  38. package/src/ui/animation.ts +2 -0
  39. package/src/ui/ansi.ts +2 -0
  40. package/src/ui/cli.ts +2 -0
  41. package/src/ui/display.ts +2 -0
  42. package/src/ui/image.ts +2 -0
  43. package/src/ui/input.ts +2 -0
  44. package/src/ui/progress.ts +2 -0
  45. package/src/ui/react.ts +2 -0
  46. package/src/ui/utils.ts +2 -0
  47. package/src/ui/wrappers.ts +2 -0
  48. package/src/ui.ts +4 -0
  49. package/examples/CLAUDE.md +0 -75
  50. package/examples/_banner.tsx +0 -60
  51. package/examples/cli.ts +0 -228
  52. package/examples/index.md +0 -101
  53. package/examples/inline/inline-nontty.tsx +0 -98
  54. package/examples/inline/inline-progress.tsx +0 -79
  55. package/examples/inline/inline-simple.tsx +0 -63
  56. package/examples/inline/scrollback.tsx +0 -185
  57. package/examples/interactive/_input-debug.tsx +0 -110
  58. package/examples/interactive/_stdin-test.ts +0 -71
  59. package/examples/interactive/_textarea-bare.tsx +0 -45
  60. package/examples/interactive/aichat/components.tsx +0 -468
  61. package/examples/interactive/aichat/index.tsx +0 -207
  62. package/examples/interactive/aichat/script.ts +0 -460
  63. package/examples/interactive/aichat/state.ts +0 -326
  64. package/examples/interactive/aichat/types.ts +0 -19
  65. package/examples/interactive/app-todo.tsx +0 -198
  66. package/examples/interactive/async-data.tsx +0 -208
  67. package/examples/interactive/cli-wizard.tsx +0 -332
  68. package/examples/interactive/clipboard.tsx +0 -183
  69. package/examples/interactive/components.tsx +0 -463
  70. package/examples/interactive/data-explorer.tsx +0 -506
  71. package/examples/interactive/dev-tools.tsx +0 -379
  72. package/examples/interactive/explorer.tsx +0 -747
  73. package/examples/interactive/gallery.tsx +0 -652
  74. package/examples/interactive/inline-bench.tsx +0 -136
  75. package/examples/interactive/kanban.tsx +0 -267
  76. package/examples/interactive/layout-ref.tsx +0 -185
  77. package/examples/interactive/outline.tsx +0 -171
  78. package/examples/interactive/paste-demo.tsx +0 -198
  79. package/examples/interactive/scroll.tsx +0 -77
  80. package/examples/interactive/search-filter.tsx +0 -240
  81. package/examples/interactive/task-list.tsx +0 -279
  82. package/examples/interactive/terminal.tsx +0 -798
  83. package/examples/interactive/textarea.tsx +0 -103
  84. package/examples/interactive/theme.tsx +0 -336
  85. package/examples/interactive/transform.tsx +0 -256
  86. package/examples/interactive/virtual-10k.tsx +0 -413
  87. package/examples/kitty/canvas.tsx +0 -519
  88. package/examples/kitty/generate-samples.ts +0 -236
  89. package/examples/kitty/image-component.tsx +0 -273
  90. package/examples/kitty/images.tsx +0 -604
  91. package/examples/kitty/input.tsx +0 -371
  92. package/examples/kitty/keys.tsx +0 -378
  93. package/examples/kitty/paint.tsx +0 -1017
  94. package/examples/layout/dashboard.tsx +0 -551
  95. package/examples/layout/live-resize.tsx +0 -290
  96. package/examples/layout/overflow.tsx +0 -51
  97. package/examples/playground/README.md +0 -69
  98. package/examples/playground/build.ts +0 -61
  99. package/examples/playground/index.html +0 -420
  100. package/examples/playground/playground-app.tsx +0 -416
  101. package/examples/runtime/elm-counter.tsx +0 -206
  102. package/examples/runtime/hello-runtime.tsx +0 -73
  103. package/examples/runtime/pipe-composition.tsx +0 -184
  104. package/examples/runtime/run-counter.tsx +0 -78
  105. package/examples/runtime/runtime-counter.tsx +0 -197
  106. package/examples/screenshots/generate.tsx +0 -563
  107. package/examples/scrollback-perf.tsx +0 -230
  108. package/examples/viewer.tsx +0 -654
  109. package/examples/web/build.ts +0 -365
  110. package/examples/web/canvas-app.tsx +0 -80
  111. package/examples/web/canvas.html +0 -89
  112. package/examples/web/dom-app.tsx +0 -81
  113. package/examples/web/dom.html +0 -113
  114. package/examples/web/showcase-app.tsx +0 -107
  115. package/examples/web/showcase.html +0 -34
  116. package/examples/web/showcases/index.tsx +0 -56
  117. package/examples/web/viewer-app.tsx +0 -555
  118. package/examples/web/viewer.html +0 -30
  119. package/examples/web/xterm-app.tsx +0 -105
  120. package/examples/web/xterm.html +0 -118
@@ -1,71 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Bare stdin test — no React, no silvery.
4
- *
5
- * Tests whether Bun's stdin "readable" events deliver all keypresses.
6
- * Run in a real terminal (not through a tool): bun vendor/silvery/examples/interactive/_stdin-test.ts
7
- *
8
- * Type characters slowly. Each keypress should appear immediately.
9
- * Press Ctrl+C to exit.
10
- */
11
-
12
- const fs = await import("node:fs")
13
-
14
- const log = fs.createWriteStream("/tmp/stdin-test.log", { flags: "w" })
15
- function logMsg(msg: string) {
16
- const ts = new Date().toISOString().slice(11, 23)
17
- log.write(`[${ts}] ${msg}\n`)
18
- process.stdout.write(msg + "\n")
19
- }
20
-
21
- logMsg("=== Bare stdin readable test ===")
22
- logMsg("Type characters slowly. Press Ctrl+C to exit.")
23
- logMsg("")
24
-
25
- process.stdin.setEncoding("utf8")
26
- process.stdin.setRawMode(true)
27
- process.stdin.ref()
28
-
29
- let count = 0
30
-
31
- function handleReadable() {
32
- const chunk = process.stdin.read() as string | null
33
- if (chunk === null) {
34
- logMsg(` readable event but stdin.read() returned null`)
35
- return
36
- }
37
-
38
- for (let i = 0; i < chunk.length; i++) {
39
- count++
40
- const ch = chunk[i]!
41
- const code = ch.charCodeAt(0)
42
-
43
- if (code === 3) {
44
- // Ctrl+C
45
- logMsg(`\n=== Total: ${count} keypresses ===`)
46
- log.end()
47
- process.stdin.setRawMode(false)
48
- process.stdin.unref()
49
- process.exit(0)
50
- }
51
-
52
- if (code >= 32 && code < 127) {
53
- logMsg(`#${count} char='${ch}' code=${code} chunkLen=${chunk.length}`)
54
- } else {
55
- logMsg(
56
- `#${count} code=0x${code.toString(16).padStart(2, "0")} chunkLen=${chunk.length} raw=${JSON.stringify(chunk.slice(i))}`,
57
- )
58
- }
59
- }
60
-
61
- // Try reading again in case more data
62
- const more = process.stdin.read() as string | null
63
- if (more !== null) {
64
- logMsg(` EXTRA data in buffer: ${JSON.stringify(more)}`)
65
- }
66
- }
67
-
68
- process.stdin.on("readable", handleReadable)
69
-
70
- logMsg("Listening for stdin readable events...")
71
- logMsg("")
@@ -1,45 +0,0 @@
1
- /**
2
- * Bare TextArea Test — single useInput hook, no parent useInput.
3
- *
4
- * Tests whether the bug is from two useInput hooks competing.
5
- * Run: bun vendor/silvery/examples/interactive/_textarea-bare.tsx
6
- *
7
- * If chars are STILL eaten → bug is in TextArea or input pipeline
8
- * If chars are NOT eaten → bug is from two useInput hooks
9
- */
10
-
11
- import React, { useState } from "react"
12
- import { render, Box, Text, TextArea, createTerm } from "../../src/index.js"
13
-
14
- function BareTextArea(): JSX.Element {
15
- const [value, setValue] = useState("")
16
-
17
- // NO parent useInput — only TextArea's internal one
18
- return (
19
- <Box flexDirection="column" padding={1}>
20
- <Text bold color="cyan">
21
- Bare TextArea (single useInput hook)
22
- </Text>
23
- <Text dim>Type slowly. Ctrl+C to exit.</Text>
24
- <Box height={1} />
25
-
26
- <Box borderStyle="single" borderColor="cyan" paddingX={1}>
27
- <TextArea value={value} onChange={setValue} height={4} placeholder="Type here..." />
28
- </Box>
29
-
30
- <Box marginTop={1}>
31
- <Text>
32
- Value ({value.length} chars): {JSON.stringify(value)}
33
- </Text>
34
- </Box>
35
- </Box>
36
- )
37
- }
38
-
39
- async function main() {
40
- using term = createTerm()
41
- const { waitUntilExit } = await render(<BareTextArea />, term)
42
- await waitUntilExit()
43
- }
44
-
45
- main().catch(console.error)
@@ -1,468 +0,0 @@
1
- /**
2
- * UI components for the AI coding agent demo.
3
- *
4
- * Components: ExchangeItem, StatusBar, DemoFooter (public)
5
- * Internal: LinkifiedLine, ThinkingBlock, ToolCallBlock, StreamingText
6
- */
7
-
8
- import React, { useState, useEffect, useCallback, useRef } from "react"
9
- import { Box, Text, Link, Spinner, TextInput, useTerminalFocused } from "silvery"
10
- import type { Exchange, ToolCall } from "./types.js"
11
- import { TOOL_COLORS, URL_RE, RANDOM_USER_COMMANDS, CONTEXT_WINDOW } from "./script.js"
12
- import type { StreamPhase } from "./state.js"
13
- import { formatTokens, formatCost, computeCumulativeTokens } from "./state.js"
14
-
15
- // ============================================================================
16
- // Footer Control — simplified interface for parent to trigger submit
17
- // ============================================================================
18
-
19
- export interface FooterControl {
20
- submit: () => void
21
- }
22
-
23
- // ============================================================================
24
- // Internal Helpers
25
- // ============================================================================
26
-
27
- /** Split content into a short title (first sentence) and the remaining body.
28
- * Title must be ≤40 chars to fit on the header line with metadata. */
29
- function splitTitleBody(content: string): { title: string; body: string } {
30
- const match = content.match(/^(.+?[.!?])\s+(.+)$/s)
31
- if (match && match[1]!.length <= 40) return { title: match[1]!, body: match[2]! }
32
- // No sentence break or sentence too long — short content goes entirely to title
33
- if (content.length <= 40) return { title: content, body: "" }
34
- return { title: "", body: content }
35
- }
36
-
37
- // ============================================================================
38
- // Internal Components
39
- // ============================================================================
40
-
41
- /** Render a line with auto-linked URLs. */
42
- function LinkifiedLine({ text, dim, color }: { text: string; dim?: boolean; color?: string }): JSX.Element {
43
- const parts: JSX.Element[] = []
44
- let lastIndex = 0
45
- let match: RegExpExecArray | null
46
-
47
- URL_RE.lastIndex = 0
48
- while ((match = URL_RE.exec(text)) !== null) {
49
- if (match.index > lastIndex) {
50
- parts.push(
51
- <Text key={`t${lastIndex}`} dim={dim} color={color}>
52
- {text.slice(lastIndex, match.index)}
53
- </Text>,
54
- )
55
- }
56
- const url = match[0]
57
- parts.push(
58
- <Link key={`l${match.index}`} href={url} dim={dim}>
59
- {url}
60
- </Link>,
61
- )
62
- lastIndex = match.index + url.length
63
- }
64
- if (lastIndex < text.length) {
65
- parts.push(
66
- <Text key={`t${lastIndex}`} dim={dim} color={color}>
67
- {text.slice(lastIndex)}
68
- </Text>,
69
- )
70
- }
71
- if (parts.length === 0) {
72
- return (
73
- <Text dim={dim} color={color}>
74
- {text}
75
- </Text>
76
- )
77
- }
78
- return <Text>{parts}</Text>
79
- }
80
-
81
- /** Thinking block — shows thinking text preview in the body. */
82
- function ThinkingBlock({ text, done }: { text: string; done: boolean }): JSX.Element {
83
- if (done)
84
- return (
85
- <Text color="$muted" italic>
86
- {"▸ thought"}
87
- </Text>
88
- )
89
- return (
90
- <Text color="$muted" wrap="truncate" italic>
91
- {text}
92
- </Text>
93
- )
94
- }
95
-
96
- /** Tool call with lifecycle: spinner -> output -> checkmark. */
97
- function ToolCallBlock({ call, phase }: { call: ToolCall; phase: "pending" | "running" | "done" }): JSX.Element {
98
- const color = TOOL_COLORS[call.tool] ?? "$muted"
99
-
100
- return (
101
- <Box flexDirection="column" marginTop={0}>
102
- <Text>
103
- {phase === "running" ? (
104
- <>
105
- <Spinner type="dots" />{" "}
106
- </>
107
- ) : phase === "done" ? (
108
- <Text color="$success">{"✓ "}</Text>
109
- ) : (
110
- <Text color="$muted">{"○ "}</Text>
111
- )}
112
- <Text color={color} bold>
113
- {call.tool}
114
- </Text>{" "}
115
- {call.tool === "Bash" || call.tool === "Grep" || call.tool === "Glob" ? (
116
- <Text color="$muted">{call.args}</Text>
117
- ) : (
118
- <Link href={`file://${call.args}`}>{call.args}</Link>
119
- )}
120
- </Text>
121
- {phase === "done" && (
122
- <Box flexDirection="column" paddingLeft={2}>
123
- {call.output.map((line, i) => {
124
- if (line.startsWith("+")) return <LinkifiedLine key={i} text={line} color="$success" />
125
- if (line.startsWith("-")) return <LinkifiedLine key={i} text={line} color="$error" />
126
- return <LinkifiedLine key={i} text={line} />
127
- })}
128
- </Box>
129
- )}
130
- </Box>
131
- )
132
- }
133
-
134
- /** Streaming text — reveals content word by word. */
135
- function StreamingText({
136
- fullText,
137
- revealFraction,
138
- showCursor,
139
- }: {
140
- fullText: string
141
- revealFraction: number
142
- showCursor: boolean
143
- }): JSX.Element {
144
- if (revealFraction >= 1) {
145
- return <Text>{fullText}</Text>
146
- }
147
-
148
- const words = fullText.split(/(\s+)/)
149
- const totalWords = words.filter((w) => w.trim()).length
150
- const revealWords = Math.ceil(totalWords * revealFraction)
151
-
152
- let wordCount = 0
153
- let revealedText = ""
154
- for (const word of words) {
155
- if (word.trim()) {
156
- wordCount++
157
- if (wordCount > revealWords) break
158
- }
159
- revealedText += word
160
- }
161
-
162
- return (
163
- <Text>
164
- {revealedText}
165
- {showCursor && <Text color="$primary">{"▌"}</Text>}
166
- </Text>
167
- )
168
- }
169
-
170
- // ============================================================================
171
- // Exchange Item — live rendering with streaming, spinners, scrollback freeze
172
- // ============================================================================
173
-
174
- export function ExchangeItem({
175
- exchange,
176
- streamPhase,
177
- revealFraction,
178
- pulse,
179
- isLatest,
180
- isFirstInGroup,
181
- isLastInGroup,
182
- }: {
183
- exchange: Exchange
184
- streamPhase: StreamPhase
185
- revealFraction: number
186
- pulse: boolean
187
- isLatest: boolean
188
- isFirstInGroup: boolean
189
- isLastInGroup: boolean
190
- }): JSX.Element {
191
- if (exchange.role === "system") {
192
- return (
193
- <Box flexDirection="column">
194
- <Text> </Text>
195
- <Text bold>AI Chat</Text>
196
- <Text> </Text>
197
- <Text color="$muted">{exchange.content}</Text>
198
- <Text> </Text>
199
- </Box>
200
- )
201
- }
202
-
203
- const isUser = exchange.role === "user"
204
-
205
- if (isUser) {
206
- return (
207
- <Box paddingX={1} flexDirection="row" backgroundColor="$surface-bg">
208
- <Text bold color="$focusring">
209
- {"❯"}{" "}
210
- </Text>
211
- <Box flexShrink={1}>
212
- <Text>{exchange.content}</Text>
213
- </Box>
214
- </Box>
215
- )
216
- }
217
-
218
- const phase = isLatest ? streamPhase : "done"
219
- const fraction = isLatest ? revealFraction : 1
220
-
221
- const toolCalls = exchange.toolCalls ?? []
222
- const toolRevealCount = phase === "tools" || phase === "done" ? toolCalls.length : 0
223
- const hasOperations = toolCalls.length > 0 || !!exchange.thinking
224
-
225
- // Metadata: token count + thought indicator
226
- const metaParts: string[] = []
227
- if (exchange.tokens && phase === "done") metaParts.push(`${formatTokens(exchange.tokens.output)} tokens`)
228
- if (exchange.thinking && (phase === "done" || phase === "streaming")) metaParts.push("thought for 1s")
229
- const metaStr = metaParts.length > 0 ? ` (${metaParts.join(" · ")})` : ""
230
-
231
- // Split content into title (first sentence) and body (rest)
232
- const { title, body } = splitTitleBody(exchange.content)
233
-
234
- const bulletColor = hasOperations ? "$success" : "$muted"
235
- const contentText = title ? body : exchange.content
236
-
237
- return (
238
- <Box flexDirection="column">
239
- <Text>
240
- <Text bold color={bulletColor} dimColor={hasOperations && !pulse && phase !== "done"}>
241
- {"●"}
242
- </Text>
243
- {phase === "thinking" ? (
244
- <Text color="$muted" italic>
245
- {" "}
246
- <Spinner type="dots" /> thinking
247
- </Text>
248
- ) : (
249
- <>
250
- {title && <Text> {title}</Text>}
251
- <Text color="$muted">{metaStr}</Text>
252
- </>
253
- )}
254
- </Text>
255
-
256
- <Box
257
- flexDirection="column"
258
- borderStyle="bold"
259
- borderColor="$border"
260
- borderLeft
261
- borderRight={false}
262
- borderTop={false}
263
- borderBottom={false}
264
- paddingLeft={1}
265
- >
266
- {exchange.thinking && (phase === "thinking" || phase === "streaming") && (
267
- <ThinkingBlock text={exchange.thinking} done={phase !== "thinking"} />
268
- )}
269
-
270
- {(phase === "streaming" || phase === "tools" || phase === "done") && contentText && (
271
- <StreamingText
272
- fullText={contentText}
273
- revealFraction={phase === "streaming" ? fraction : 1}
274
- showCursor={phase === "streaming" && fraction < 1}
275
- />
276
- )}
277
-
278
- {toolRevealCount > 0 && (
279
- <Box flexDirection="column">
280
- {toolCalls.map((call, i) => (
281
- <ToolCallBlock
282
- key={i}
283
- call={call}
284
- phase={phase === "done" ? "done" : i < toolRevealCount - 1 ? "done" : "running"}
285
- />
286
- ))}
287
- </Box>
288
- )}
289
- </Box>
290
- </Box>
291
- )
292
- }
293
-
294
- // ============================================================================
295
- // Status Bar — single compact row
296
- // ============================================================================
297
-
298
- export function StatusBar({
299
- exchanges,
300
- compacting,
301
- done,
302
- elapsed,
303
- contextBaseline = 0,
304
- ctrlDPending = false,
305
- }: {
306
- exchanges: Exchange[]
307
- compacting: boolean
308
- done: boolean
309
- elapsed: number
310
- contextBaseline?: number
311
- ctrlDPending?: boolean
312
- }): JSX.Element {
313
- const cumulative = computeCumulativeTokens(exchanges)
314
- const cost = formatCost(cumulative.input, cumulative.output)
315
- const minutes = Math.floor(elapsed / 60)
316
- const seconds = elapsed % 60
317
- const elapsedStr = `${minutes}:${seconds.toString().padStart(2, "0")}`
318
-
319
- const CTX_W = 20
320
- const effectiveContext = Math.max(0, cumulative.currentContext - contextBaseline)
321
- const ctxFrac = effectiveContext / CONTEXT_WINDOW
322
- const ctxFilled = Math.round(Math.min(ctxFrac, 1) * CTX_W)
323
- const ctxPct = Math.round(ctxFrac * 100)
324
- const ctxColor = ctxPct > 100 ? "$error" : ctxPct > 80 ? "$warning" : "$primary"
325
- const ctxBar = "█".repeat(ctxFilled) + "░".repeat(CTX_W - ctxFilled)
326
-
327
- const keys = ctrlDPending ? "Ctrl-D again to exit" : compacting ? "compacting..." : "esc quit"
328
-
329
- return (
330
- <Box flexDirection="row" justifyContent="space-between" width="100%">
331
- <Text color="$muted" wrap="truncate">
332
- {elapsedStr}
333
- {" "}
334
- {keys}
335
- </Text>
336
- <Text color={ctxPct > 80 ? ctxColor : "$muted"} wrap="truncate">
337
- ctx {ctxBar} {ctxPct}%{" "}
338
- {cost}
339
- </Text>
340
- </Box>
341
- )
342
- }
343
-
344
- // ============================================================================
345
- // Footer — owns inputText state so typing doesn't re-render the parent
346
- // ============================================================================
347
-
348
- const AUTO_SUBMIT_DELAY = 10_000
349
-
350
- export function DemoFooter({
351
- controlRef,
352
- onSubmit,
353
- streamPhase,
354
- done,
355
- compacting,
356
- exchanges,
357
- contextBaseline = 0,
358
- ctrlDPending = false,
359
- nextMessage = "",
360
- autoTypingText = null,
361
- }: {
362
- controlRef: React.RefObject<FooterControl>
363
- onSubmit: (text: string) => void
364
- streamPhase: StreamPhase
365
- done: boolean
366
- compacting: boolean
367
- exchanges: Exchange[]
368
- contextBaseline?: number
369
- ctrlDPending?: boolean
370
- nextMessage?: string
371
- autoTypingText?: string | null
372
- }): JSX.Element {
373
- const terminalFocused = useTerminalFocused()
374
- const [inputText, setInputText] = useState("")
375
- const inputTextRef = useRef(inputText)
376
- inputTextRef.current = inputText
377
-
378
- const startRef = useRef(Date.now())
379
- const [elapsed, setElapsed] = useState(0)
380
- useEffect(() => {
381
- const timer = setInterval(() => setElapsed(Math.floor((Date.now() - startRef.current) / 1000)), 1000)
382
- return () => clearInterval(timer)
383
- }, [])
384
-
385
- const [randomIdx, setRandomIdx] = useState(() => Math.floor(Math.random() * RANDOM_USER_COMMANDS.length))
386
- const randomPlaceholder = RANDOM_USER_COMMANDS[randomIdx % RANDOM_USER_COMMANDS.length]!
387
- const effectiveMessage = nextMessage || randomPlaceholder
388
- const placeholder = !terminalFocused
389
- ? "Click to focus"
390
- : ctrlDPending
391
- ? "Press Ctrl-D again to exit"
392
- : effectiveMessage
393
-
394
- const handleSubmit = useCallback(
395
- (text: string) => {
396
- if (!text.trim() && effectiveMessage) {
397
- onSubmit(effectiveMessage)
398
- } else {
399
- onSubmit(text)
400
- }
401
- setInputText("")
402
- setRandomIdx((i) => i + 1)
403
- },
404
- [onSubmit, effectiveMessage],
405
- )
406
-
407
- // Expose submit() to parent — replaces the old getText/setText/getPlaceholder pattern
408
- controlRef.current = {
409
- submit: () => handleSubmit(inputTextRef.current),
410
- }
411
-
412
- // Auto-submit: if idle for AUTO_SUBMIT_DELAY, submit the placeholder message
413
- const autoSubmitRef = useRef<ReturnType<typeof setTimeout> | null>(null)
414
- useEffect(() => {
415
- if (autoSubmitRef.current) clearTimeout(autoSubmitRef.current)
416
- if (
417
- done ||
418
- compacting ||
419
- streamPhase !== "done" ||
420
- !effectiveMessage ||
421
- inputText ||
422
- autoTypingText ||
423
- !terminalFocused
424
- )
425
- return
426
- autoSubmitRef.current = setTimeout(() => onSubmit(effectiveMessage), AUTO_SUBMIT_DELAY)
427
- return () => {
428
- if (autoSubmitRef.current) clearTimeout(autoSubmitRef.current)
429
- }
430
- }, [done, compacting, streamPhase, effectiveMessage, inputText, autoTypingText, onSubmit])
431
-
432
- const displayText = autoTypingText ?? inputText
433
-
434
- return (
435
- <Box flexDirection="column" width="100%">
436
- <Text> </Text>
437
- <Box
438
- flexDirection="row"
439
- borderStyle="round"
440
- borderColor={!done && terminalFocused ? "$focusborder" : "$inputborder"}
441
- paddingX={1}
442
- >
443
- <Text bold color="$focusring">
444
- {"❯"}{" "}
445
- </Text>
446
- <Box flexShrink={1} flexGrow={1}>
447
- <TextInput
448
- value={displayText}
449
- onChange={autoTypingText ? () => {} : setInputText}
450
- onSubmit={handleSubmit}
451
- placeholder={placeholder}
452
- isActive={!done && !autoTypingText && terminalFocused}
453
- />
454
- </Box>
455
- </Box>
456
- <Box paddingX={2} width="100%">
457
- <StatusBar
458
- exchanges={exchanges}
459
- compacting={compacting}
460
- done={done}
461
- elapsed={elapsed}
462
- contextBaseline={contextBaseline}
463
- ctrlDPending={ctrlDPending}
464
- />
465
- </Box>
466
- </Box>
467
- )
468
- }