silvery 0.3.0 → 0.4.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 (88) hide show
  1. package/README.md +41 -145
  2. package/package.json +64 -12
  3. package/src/index.ts +67 -1
  4. package/src/runtime.ts +4 -0
  5. package/src/theme.ts +4 -0
  6. package/src/ui/animation.ts +2 -0
  7. package/src/ui/ansi.ts +2 -0
  8. package/src/ui/cli.ts +2 -0
  9. package/src/ui/display.ts +2 -0
  10. package/src/ui/image.ts +2 -0
  11. package/src/ui/input.ts +2 -0
  12. package/src/ui/progress.ts +2 -0
  13. package/src/ui/react.ts +2 -0
  14. package/src/ui/utils.ts +2 -0
  15. package/src/ui/wrappers.ts +2 -0
  16. package/src/ui.ts +4 -0
  17. package/examples/CLAUDE.md +0 -75
  18. package/examples/_banner.tsx +0 -60
  19. package/examples/cli.ts +0 -228
  20. package/examples/index.md +0 -101
  21. package/examples/inline/inline-nontty.tsx +0 -98
  22. package/examples/inline/inline-progress.tsx +0 -79
  23. package/examples/inline/inline-simple.tsx +0 -63
  24. package/examples/inline/scrollback.tsx +0 -185
  25. package/examples/interactive/_input-debug.tsx +0 -110
  26. package/examples/interactive/_stdin-test.ts +0 -71
  27. package/examples/interactive/_textarea-bare.tsx +0 -45
  28. package/examples/interactive/aichat/components.tsx +0 -468
  29. package/examples/interactive/aichat/index.tsx +0 -207
  30. package/examples/interactive/aichat/script.ts +0 -460
  31. package/examples/interactive/aichat/state.ts +0 -326
  32. package/examples/interactive/aichat/types.ts +0 -19
  33. package/examples/interactive/app-todo.tsx +0 -198
  34. package/examples/interactive/async-data.tsx +0 -208
  35. package/examples/interactive/cli-wizard.tsx +0 -332
  36. package/examples/interactive/clipboard.tsx +0 -183
  37. package/examples/interactive/components.tsx +0 -463
  38. package/examples/interactive/data-explorer.tsx +0 -506
  39. package/examples/interactive/dev-tools.tsx +0 -379
  40. package/examples/interactive/explorer.tsx +0 -747
  41. package/examples/interactive/gallery.tsx +0 -652
  42. package/examples/interactive/inline-bench.tsx +0 -136
  43. package/examples/interactive/kanban.tsx +0 -267
  44. package/examples/interactive/layout-ref.tsx +0 -185
  45. package/examples/interactive/outline.tsx +0 -171
  46. package/examples/interactive/paste-demo.tsx +0 -198
  47. package/examples/interactive/scroll.tsx +0 -77
  48. package/examples/interactive/search-filter.tsx +0 -240
  49. package/examples/interactive/task-list.tsx +0 -279
  50. package/examples/interactive/terminal.tsx +0 -798
  51. package/examples/interactive/textarea.tsx +0 -103
  52. package/examples/interactive/theme.tsx +0 -336
  53. package/examples/interactive/transform.tsx +0 -256
  54. package/examples/interactive/virtual-10k.tsx +0 -413
  55. package/examples/kitty/canvas.tsx +0 -519
  56. package/examples/kitty/generate-samples.ts +0 -236
  57. package/examples/kitty/image-component.tsx +0 -273
  58. package/examples/kitty/images.tsx +0 -604
  59. package/examples/kitty/input.tsx +0 -371
  60. package/examples/kitty/keys.tsx +0 -378
  61. package/examples/kitty/paint.tsx +0 -1017
  62. package/examples/layout/dashboard.tsx +0 -551
  63. package/examples/layout/live-resize.tsx +0 -290
  64. package/examples/layout/overflow.tsx +0 -51
  65. package/examples/playground/README.md +0 -69
  66. package/examples/playground/build.ts +0 -61
  67. package/examples/playground/index.html +0 -420
  68. package/examples/playground/playground-app.tsx +0 -416
  69. package/examples/runtime/elm-counter.tsx +0 -206
  70. package/examples/runtime/hello-runtime.tsx +0 -73
  71. package/examples/runtime/pipe-composition.tsx +0 -184
  72. package/examples/runtime/run-counter.tsx +0 -78
  73. package/examples/runtime/runtime-counter.tsx +0 -197
  74. package/examples/screenshots/generate.tsx +0 -563
  75. package/examples/scrollback-perf.tsx +0 -230
  76. package/examples/viewer.tsx +0 -654
  77. package/examples/web/build.ts +0 -365
  78. package/examples/web/canvas-app.tsx +0 -80
  79. package/examples/web/canvas.html +0 -89
  80. package/examples/web/dom-app.tsx +0 -81
  81. package/examples/web/dom.html +0 -113
  82. package/examples/web/showcase-app.tsx +0 -107
  83. package/examples/web/showcase.html +0 -34
  84. package/examples/web/showcases/index.tsx +0 -56
  85. package/examples/web/viewer-app.tsx +0 -555
  86. package/examples/web/viewer.html +0 -30
  87. package/examples/web/xterm-app.tsx +0 -105
  88. package/examples/web/xterm.html +0 -118
@@ -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
- }
@@ -1,207 +0,0 @@
1
- /**
2
- * AI Chat — Coding Agent Demo
3
- *
4
- * Showcases ScrollbackList with streaming, tool calls, context tracking.
5
- * TEA state machine drives all animation; ScrollbackList freezes completed
6
- * exchanges into real terminal scrollback with colors, borders, and hyperlinks.
7
- *
8
- * Flags: --auto (auto-advance) --fast (skip animation) --stress (200 exchanges)
9
- */
10
-
11
- import React, { useEffect, useRef, useMemo } from "react"
12
- import { Box, Text, Spinner, ScrollbackList, useTea } from "silvery"
13
- import { run, useInput, useExit, type Key } from "@silvery/term/runtime"
14
- import type { ExampleMeta } from "../../_banner.js"
15
- import type { ScriptEntry } from "./types.js"
16
- import { SCRIPT, generateStressScript, CONTEXT_WINDOW } from "./script.js"
17
- import {
18
- INIT_STATE,
19
- createDemoUpdate,
20
- computeCumulativeTokens,
21
- getNextMessage,
22
- type DemoState,
23
- type DemoMsg,
24
- } from "./state.js"
25
- import { ExchangeItem, DemoFooter } from "./components.js"
26
- import type { FooterControl } from "./components.js"
27
-
28
- // Re-export for test consumers
29
- export { SCRIPT, generateStressScript, CONTEXT_WINDOW } from "./script.js"
30
- export type { ScriptEntry } from "./types.js"
31
- export type { Exchange, ToolCall } from "./types.js"
32
-
33
- export const meta: ExampleMeta = {
34
- name: "AI Coding Agent",
35
- description: "Coding agent showcase — ScrollbackList, streaming, context tracking",
36
- demo: true,
37
- features: ["ScrollbackList", "auto-freeze", "inline mode", "streaming", "OSC 8 links", "OSC 133 markers"],
38
- }
39
-
40
- // ============================================================================
41
- // AIChat — TEA state machine + ScrollbackList
42
- // ============================================================================
43
-
44
- export function AIChat({
45
- script,
46
- autoStart,
47
- fastMode,
48
- }: {
49
- script: ScriptEntry[]
50
- autoStart: boolean
51
- fastMode: boolean
52
- }): JSX.Element {
53
- const exit = useExit()
54
- const update = useMemo(() => createDemoUpdate(script, fastMode, autoStart), [script, fastMode, autoStart])
55
- const [state, send] = useTea(INIT_STATE, update)
56
- const footerControlRef = useRef<FooterControl>({ submit: () => {} })
57
-
58
- useEffect(() => send({ type: "mount" }), [send])
59
- useAutoCompact(state, send)
60
- useAutoExit(autoStart, state.done, exit)
61
- useKeyBindings(state, send, footerControlRef)
62
-
63
- return (
64
- <Box flexDirection="column" paddingX={1}>
65
- <ScrollbackList
66
- items={state.exchanges}
67
- keyExtractor={(ex) => ex.id}
68
- markers={true}
69
- footer={
70
- <DemoFooter
71
- controlRef={footerControlRef}
72
- onSubmit={(text) => send({ type: "submit", text })}
73
- streamPhase={state.streamPhase}
74
- done={state.done}
75
- compacting={state.compacting}
76
- exchanges={state.exchanges}
77
- contextBaseline={state.contextBaseline}
78
- ctrlDPending={state.ctrlDPending}
79
- nextMessage={getNextMessage(state, script, autoStart)}
80
- autoTypingText={state.autoTyping ? state.autoTyping.full.slice(0, state.autoTyping.revealed) : null}
81
- />
82
- }
83
- >
84
- {(exchange, index) => {
85
- const isLatest = index === state.exchanges.length - 1
86
- return (
87
- <Box flexDirection="column">
88
- {index > 0 && <Text> </Text>}
89
- {state.compacting && isLatest && <CompactingOverlay />}
90
- {state.done && autoStart && isLatest && <SessionComplete />}
91
- <ExchangeItem
92
- exchange={exchange}
93
- streamPhase={state.streamPhase}
94
- revealFraction={state.revealFraction}
95
- pulse={state.pulse}
96
- isLatest={isLatest}
97
- isFirstInGroup={exchange.role !== (index > 0 ? state.exchanges[index - 1]!.role : null)}
98
- isLastInGroup={
99
- exchange.role !== (index < state.exchanges.length - 1 ? state.exchanges[index + 1]!.role : null)
100
- }
101
- />
102
- </Box>
103
- )
104
- }}
105
- </ScrollbackList>
106
- </Box>
107
- )
108
- }
109
-
110
- // ============================================================================
111
- // Main
112
- // ============================================================================
113
-
114
- export async function main() {
115
- const args = process.argv.slice(2)
116
- const script = args.includes("--stress") ? generateStressScript() : SCRIPT
117
- const mode = args.includes("--fullscreen") ? "fullscreen" : "inline"
118
-
119
- using handle = await run(
120
- <AIChat script={script} autoStart={args.includes("--auto")} fastMode={args.includes("--fast")} />,
121
- { mode: mode as "inline" | "fullscreen", focusReporting: true },
122
- )
123
- await handle.waitUntilExit()
124
- }
125
-
126
- if (import.meta.main) {
127
- main().catch(console.error)
128
- }
129
-
130
- // ============================================================================
131
- // Hooks
132
- // ============================================================================
133
-
134
- function useAutoCompact(state: DemoState, send: (msg: DemoMsg) => void) {
135
- useEffect(() => {
136
- if (state.done || state.compacting) return
137
- const cumulative = computeCumulativeTokens(state.exchanges)
138
- const effective = Math.max(0, cumulative.currentContext - state.contextBaseline)
139
- if (effective >= CONTEXT_WINDOW * 0.95) send({ type: "compact" })
140
- }, [state.exchanges, state.done, state.compacting, state.contextBaseline, send])
141
- }
142
-
143
- function useAutoExit(autoStart: boolean, done: boolean, exit: () => void) {
144
- useEffect(() => {
145
- if (!autoStart || !done) return
146
- const timer = setTimeout(exit, 1000)
147
- return () => clearTimeout(timer)
148
- }, [autoStart, done, exit])
149
- }
150
-
151
- function useKeyBindings(
152
- state: DemoState,
153
- send: (msg: DemoMsg) => void,
154
- footerControlRef: React.RefObject<FooterControl>,
155
- ) {
156
- const lastCtrlDRef = useRef(0)
157
-
158
- useInput((input: string, key: Key) => {
159
- if (key.escape) return "exit"
160
- if (key.ctrl && input === "d") {
161
- const now = Date.now()
162
- if (now - lastCtrlDRef.current < 500) return "exit"
163
- lastCtrlDRef.current = now
164
- send({ type: "setCtrlDPending", pending: true })
165
- return
166
- }
167
- if (lastCtrlDRef.current > 0) {
168
- lastCtrlDRef.current = 0
169
- send({ type: "setCtrlDPending", pending: false })
170
- }
171
- if (key.tab) {
172
- if (state.done || state.compacting) return
173
- footerControlRef.current.submit()
174
- return
175
- }
176
- if (key.ctrl && input === "l") {
177
- send({ type: "compact" })
178
- }
179
- })
180
- }
181
-
182
- // ============================================================================
183
- // Inline UI fragments
184
- // ============================================================================
185
-
186
- function CompactingOverlay(): JSX.Element {
187
- return (
188
- <Box flexDirection="column" borderStyle="round" borderColor="$warning" paddingX={1} overflow="hidden">
189
- <Text color="$warning" bold>
190
- <Spinner type="arc" /> Compacting context
191
- </Text>
192
- <Text> </Text>
193
- <Text color="$muted">Freezing exchanges into terminal scrollback. Scroll up to review.</Text>
194
- </Box>
195
- )
196
- }
197
-
198
- function SessionComplete(): JSX.Element {
199
- return (
200
- <Box flexDirection="column" borderStyle="round" borderColor="$success" paddingX={1}>
201
- <Text color="$success" bold>
202
- {"✓"} Session complete
203
- </Text>
204
- <Text color="$muted">Scroll up to review — colors, borders, and hyperlinks preserved in scrollback.</Text>
205
- </Box>
206
- )
207
- }