@silvery/examples 0.5.6 → 0.17.4

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 (112) hide show
  1. package/dist/UPNG-Cy7ViL8f.mjs +5074 -0
  2. package/dist/__vite-browser-external-2447137e-BML7CYau.mjs +4 -0
  3. package/dist/_banner-DLPxCqVy.mjs +44 -0
  4. package/dist/ansi-CCE2pVS0.mjs +16397 -0
  5. package/dist/apng-HhhBjRGt.mjs +68 -0
  6. package/dist/apng-mwUQbTTF.mjs +3 -0
  7. package/dist/apps/aichat/index.mjs +1299 -0
  8. package/dist/apps/app-todo.mjs +139 -0
  9. package/dist/apps/async-data.mjs +204 -0
  10. package/dist/apps/cli-wizard.mjs +339 -0
  11. package/dist/apps/clipboard.mjs +198 -0
  12. package/dist/apps/components.mjs +864 -0
  13. package/dist/apps/data-explorer.mjs +483 -0
  14. package/dist/apps/dev-tools.mjs +397 -0
  15. package/dist/apps/explorer.mjs +698 -0
  16. package/dist/apps/gallery.mjs +766 -0
  17. package/dist/apps/inline-bench.mjs +115 -0
  18. package/dist/apps/kanban.mjs +280 -0
  19. package/dist/apps/layout-ref.mjs +187 -0
  20. package/dist/apps/outline.mjs +203 -0
  21. package/dist/apps/paste-demo.mjs +189 -0
  22. package/dist/apps/scroll.mjs +86 -0
  23. package/dist/apps/search-filter.mjs +287 -0
  24. package/dist/apps/selection.mjs +355 -0
  25. package/dist/apps/spatial-focus-demo.mjs +388 -0
  26. package/dist/apps/task-list.mjs +258 -0
  27. package/dist/apps/terminal-caps-demo.mjs +315 -0
  28. package/dist/apps/terminal.mjs +872 -0
  29. package/dist/apps/text-selection-demo.mjs +254 -0
  30. package/dist/apps/textarea.mjs +178 -0
  31. package/dist/apps/theme.mjs +661 -0
  32. package/dist/apps/transform.mjs +215 -0
  33. package/dist/apps/virtual-10k.mjs +422 -0
  34. package/dist/assets/resvgjs.darwin-arm64-BtufyGW1.node +0 -0
  35. package/dist/backends-Bahh9mKN.mjs +1179 -0
  36. package/dist/backends-CCtCDQ94.mjs +3 -0
  37. package/dist/{cli.mjs → bin/cli.mjs} +21 -25
  38. package/dist/chunk-BSw8zbkd.mjs +37 -0
  39. package/dist/components/counter.mjs +48 -0
  40. package/dist/components/hello.mjs +31 -0
  41. package/dist/components/progress-bar.mjs +59 -0
  42. package/dist/components/select-list.mjs +85 -0
  43. package/dist/components/spinner.mjs +57 -0
  44. package/dist/components/text-input.mjs +62 -0
  45. package/dist/components/virtual-list.mjs +51 -0
  46. package/dist/flexily-zero-adapter-UB-ra8fR.mjs +3374 -0
  47. package/dist/gif-BZaqPPVX.mjs +3 -0
  48. package/dist/gif-BtnXuxLF.mjs +71 -0
  49. package/dist/gifenc-CLRW41dk.mjs +728 -0
  50. package/dist/jsx-runtime-dMs_8fNu.mjs +241 -0
  51. package/dist/key-mapping-5oYQdAQE.mjs +3 -0
  52. package/dist/key-mapping-D4LR1go6.mjs +130 -0
  53. package/dist/layout/dashboard.mjs +1204 -0
  54. package/dist/layout/live-resize.mjs +303 -0
  55. package/dist/layout/overflow.mjs +70 -0
  56. package/dist/layout/text-layout.mjs +335 -0
  57. package/dist/node-NuJ94BWl.mjs +1083 -0
  58. package/dist/plugins-D1KtkT4a.mjs +3057 -0
  59. package/dist/resvg-js-C_8Wps1F.mjs +201 -0
  60. package/dist/src-BTEVGpd9.mjs +23538 -0
  61. package/dist/src-CUUOuRH6.mjs +5322 -0
  62. package/dist/src-CzfRafCQ.mjs +814 -0
  63. package/dist/usingCtx-CsEf0xO3.mjs +57 -0
  64. package/dist/yoga-adapter-BVtQ5OJR.mjs +237 -0
  65. package/package.json +19 -14
  66. package/_banner.tsx +0 -60
  67. package/apps/aichat/components.tsx +0 -469
  68. package/apps/aichat/index.tsx +0 -220
  69. package/apps/aichat/script.ts +0 -460
  70. package/apps/aichat/state.ts +0 -325
  71. package/apps/aichat/types.ts +0 -19
  72. package/apps/app-todo.tsx +0 -201
  73. package/apps/async-data.tsx +0 -196
  74. package/apps/cli-wizard.tsx +0 -332
  75. package/apps/clipboard.tsx +0 -183
  76. package/apps/components.tsx +0 -658
  77. package/apps/data-explorer.tsx +0 -490
  78. package/apps/dev-tools.tsx +0 -395
  79. package/apps/explorer.tsx +0 -731
  80. package/apps/gallery.tsx +0 -653
  81. package/apps/inline-bench.tsx +0 -138
  82. package/apps/kanban.tsx +0 -265
  83. package/apps/layout-ref.tsx +0 -173
  84. package/apps/outline.tsx +0 -160
  85. package/apps/panes/index.tsx +0 -203
  86. package/apps/paste-demo.tsx +0 -185
  87. package/apps/scroll.tsx +0 -77
  88. package/apps/search-filter.tsx +0 -240
  89. package/apps/selection.tsx +0 -342
  90. package/apps/spatial-focus-demo.tsx +0 -368
  91. package/apps/task-list.tsx +0 -271
  92. package/apps/terminal-caps-demo.tsx +0 -334
  93. package/apps/terminal.tsx +0 -800
  94. package/apps/text-selection-demo.tsx +0 -189
  95. package/apps/textarea.tsx +0 -155
  96. package/apps/theme.tsx +0 -515
  97. package/apps/transform.tsx +0 -229
  98. package/apps/virtual-10k.tsx +0 -405
  99. package/apps/vterm-demo/index.tsx +0 -216
  100. package/components/counter.tsx +0 -45
  101. package/components/hello.tsx +0 -34
  102. package/components/progress-bar.tsx +0 -48
  103. package/components/select-list.tsx +0 -50
  104. package/components/spinner.tsx +0 -40
  105. package/components/text-input.tsx +0 -57
  106. package/components/virtual-list.tsx +0 -52
  107. package/dist/cli.d.mts +0 -1
  108. package/dist/cli.mjs.map +0 -1
  109. package/layout/dashboard.tsx +0 -953
  110. package/layout/live-resize.tsx +0 -282
  111. package/layout/overflow.tsx +0 -51
  112. package/layout/text-layout.tsx +0 -283
@@ -1,469 +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 type { JSX } from "react"
9
- import React, { useState, useEffect, useCallback, useRef } from "react"
10
- import { Box, Text, Link, Spinner, TextInput, useTerminalFocused } from "silvery"
11
- import type { Exchange, ToolCall } from "./types.js"
12
- import { TOOL_COLORS, URL_RE, RANDOM_USER_COMMANDS, CONTEXT_WINDOW } from "./script.js"
13
- import type { StreamPhase } from "./state.js"
14
- import { formatTokens, formatCost, computeCumulativeTokens } from "./state.js"
15
-
16
- // ============================================================================
17
- // Footer Control — simplified interface for parent to trigger submit
18
- // ============================================================================
19
-
20
- export interface FooterControl {
21
- submit: () => void
22
- }
23
-
24
- // ============================================================================
25
- // Internal Helpers
26
- // ============================================================================
27
-
28
- /** Split content into a short title (first sentence) and the remaining body.
29
- * Title must be ≤40 chars to fit on the header line with metadata. */
30
- function splitTitleBody(content: string): { title: string; body: string } {
31
- const match = content.match(/^(.+?[.!?])\s+(.+)$/s)
32
- if (match && match[1]!.length <= 40) return { title: match[1]!, body: match[2]! }
33
- // No sentence break or sentence too long — short content goes entirely to title
34
- if (content.length <= 40) return { title: content, body: "" }
35
- return { title: "", body: content }
36
- }
37
-
38
- // ============================================================================
39
- // Internal Components
40
- // ============================================================================
41
-
42
- /** Render a line with auto-linked URLs. */
43
- function LinkifiedLine({ text, dim, color }: { text: string; dim?: boolean; color?: string }) {
44
- const parts: JSX.Element[] = []
45
- let lastIndex = 0
46
- let match: RegExpExecArray | null
47
-
48
- URL_RE.lastIndex = 0
49
- while ((match = URL_RE.exec(text)) !== null) {
50
- if (match.index > lastIndex) {
51
- parts.push(
52
- <Text key={`t${lastIndex}`} dim={dim} color={color}>
53
- {text.slice(lastIndex, match.index)}
54
- </Text>,
55
- )
56
- }
57
- const url = match[0]
58
- parts.push(
59
- <Link key={`l${match.index}`} href={url} dim={dim}>
60
- {url}
61
- </Link>,
62
- )
63
- lastIndex = match.index + url.length
64
- }
65
- if (lastIndex < text.length) {
66
- parts.push(
67
- <Text key={`t${lastIndex}`} dim={dim} color={color}>
68
- {text.slice(lastIndex)}
69
- </Text>,
70
- )
71
- }
72
- if (parts.length === 0) {
73
- return (
74
- <Text dim={dim} color={color}>
75
- {text}
76
- </Text>
77
- )
78
- }
79
- return <Text>{parts}</Text>
80
- }
81
-
82
- /** Thinking block — shows thinking text preview in the body. */
83
- function ThinkingBlock({ text, done }: { text: string; done: boolean }) {
84
- if (done)
85
- return (
86
- <Text color="$muted" italic>
87
- {"▸ thought"}
88
- </Text>
89
- )
90
- return (
91
- <Text color="$muted" wrap="truncate" italic>
92
- {text}
93
- </Text>
94
- )
95
- }
96
-
97
- /** Tool call with lifecycle: spinner -> output -> checkmark. */
98
- function ToolCallBlock({ call, phase }: { call: ToolCall; phase: "pending" | "running" | "done" }) {
99
- const color = TOOL_COLORS[call.tool] ?? "$muted"
100
-
101
- return (
102
- <Box flexDirection="column" marginTop={0}>
103
- <Text>
104
- {phase === "running" ? (
105
- <>
106
- <Spinner type="dots" />{" "}
107
- </>
108
- ) : phase === "done" ? (
109
- <Text color="$success">{"✓ "}</Text>
110
- ) : (
111
- <Text color="$muted">{"○ "}</Text>
112
- )}
113
- <Text color={color} bold>
114
- {call.tool}
115
- </Text>{" "}
116
- {call.tool === "Bash" || call.tool === "Grep" || call.tool === "Glob" ? (
117
- <Text color="$muted">{call.args}</Text>
118
- ) : (
119
- <Link href={`file://${call.args}`}>{call.args}</Link>
120
- )}
121
- </Text>
122
- {phase === "done" && (
123
- <Box flexDirection="column" paddingLeft={2}>
124
- {call.output.map((line, i) => {
125
- if (line.startsWith("+")) return <LinkifiedLine key={i} text={line} color="$success" />
126
- if (line.startsWith("-")) return <LinkifiedLine key={i} text={line} color="$error" />
127
- return <LinkifiedLine key={i} text={line} />
128
- })}
129
- </Box>
130
- )}
131
- </Box>
132
- )
133
- }
134
-
135
- /** Streaming text — reveals content word by word. */
136
- function StreamingText({
137
- fullText,
138
- revealFraction,
139
- showCursor,
140
- }: {
141
- fullText: string
142
- revealFraction: number
143
- showCursor: boolean
144
- }) {
145
- if (revealFraction >= 1) {
146
- return <Text>{fullText}</Text>
147
- }
148
-
149
- const words = fullText.split(/(\s+)/)
150
- const totalWords = words.filter((w) => w.trim()).length
151
- const revealWords = Math.ceil(totalWords * revealFraction)
152
-
153
- let wordCount = 0
154
- let revealedText = ""
155
- for (const word of words) {
156
- if (word.trim()) {
157
- wordCount++
158
- if (wordCount > revealWords) break
159
- }
160
- revealedText += word
161
- }
162
-
163
- return (
164
- <Text>
165
- {revealedText}
166
- {showCursor && <Text color="$primary">{"▌"}</Text>}
167
- </Text>
168
- )
169
- }
170
-
171
- // ============================================================================
172
- // Exchange Item — live rendering with streaming, spinners, scrollback freeze
173
- // ============================================================================
174
-
175
- export function ExchangeItem({
176
- exchange,
177
- streamPhase,
178
- revealFraction,
179
- pulse,
180
- isLatest,
181
- isFirstInGroup,
182
- isLastInGroup,
183
- }: {
184
- exchange: Exchange
185
- streamPhase: StreamPhase
186
- revealFraction: number
187
- pulse: boolean
188
- isLatest: boolean
189
- isFirstInGroup: boolean
190
- isLastInGroup: boolean
191
- }) {
192
- if (exchange.role === "system") {
193
- return (
194
- <Box flexDirection="column">
195
- <Text> </Text>
196
- <Text bold>AI Chat</Text>
197
- <Text> </Text>
198
- <Text color="$muted">{exchange.content}</Text>
199
- <Text> </Text>
200
- </Box>
201
- )
202
- }
203
-
204
- const isUser = exchange.role === "user"
205
-
206
- if (isUser) {
207
- return (
208
- <Box paddingX={1} flexDirection="row" backgroundColor="$surface-bg">
209
- <Text bold color="$focusring">
210
- {"❯"}{" "}
211
- </Text>
212
- <Box flexShrink={1}>
213
- <Text>{exchange.content}</Text>
214
- </Box>
215
- </Box>
216
- )
217
- }
218
-
219
- const phase = isLatest ? streamPhase : "done"
220
- const fraction = isLatest ? revealFraction : 1
221
-
222
- const toolCalls = exchange.toolCalls ?? []
223
- const toolRevealCount = phase === "tools" || phase === "done" ? toolCalls.length : 0
224
- const hasOperations = toolCalls.length > 0 || !!exchange.thinking
225
-
226
- // Metadata: token count + thought indicator
227
- const metaParts: string[] = []
228
- if (exchange.tokens && phase === "done") metaParts.push(`${formatTokens(exchange.tokens.output)} tokens`)
229
- if (exchange.thinking && (phase === "done" || phase === "streaming")) metaParts.push("thought for 1s")
230
- const metaStr = metaParts.length > 0 ? ` (${metaParts.join(" · ")})` : ""
231
-
232
- // Split content into title (first sentence) and body (rest)
233
- const { title, body } = splitTitleBody(exchange.content)
234
-
235
- const bulletColor = hasOperations ? "$success" : "$muted"
236
- const contentText = title ? body : exchange.content
237
-
238
- return (
239
- <Box flexDirection="column">
240
- <Text>
241
- <Text bold color={bulletColor} dimColor={hasOperations && !pulse && phase !== "done"}>
242
- {"●"}
243
- </Text>
244
- {phase === "thinking" ? (
245
- <Text color="$muted" italic>
246
- {" "}
247
- <Spinner type="dots" /> thinking
248
- </Text>
249
- ) : (
250
- <>
251
- {title && <Text> {title}</Text>}
252
- <Text color="$muted">{metaStr}</Text>
253
- </>
254
- )}
255
- </Text>
256
-
257
- <Box
258
- flexDirection="column"
259
- borderStyle="bold"
260
- borderColor="$border"
261
- borderLeft
262
- borderRight={false}
263
- borderTop={false}
264
- borderBottom={false}
265
- paddingLeft={1}
266
- >
267
- {exchange.thinking && (phase === "thinking" || phase === "streaming") && (
268
- <ThinkingBlock text={exchange.thinking} done={phase !== "thinking"} />
269
- )}
270
-
271
- {(phase === "streaming" || phase === "tools" || phase === "done") && contentText && (
272
- <StreamingText
273
- fullText={contentText}
274
- revealFraction={phase === "streaming" ? fraction : 1}
275
- showCursor={phase === "streaming" && fraction < 1}
276
- />
277
- )}
278
-
279
- {toolRevealCount > 0 && (
280
- <Box flexDirection="column">
281
- {toolCalls.map((call, i) => (
282
- <ToolCallBlock
283
- key={i}
284
- call={call}
285
- phase={phase === "done" ? "done" : i < toolRevealCount - 1 ? "done" : "running"}
286
- />
287
- ))}
288
- </Box>
289
- )}
290
- </Box>
291
- </Box>
292
- )
293
- }
294
-
295
- // ============================================================================
296
- // Status Bar — single compact row
297
- // ============================================================================
298
-
299
- export function StatusBar({
300
- exchanges,
301
- compacting,
302
- done,
303
- elapsed,
304
- contextBaseline = 0,
305
- ctrlDPending = false,
306
- }: {
307
- exchanges: Exchange[]
308
- compacting: boolean
309
- done: boolean
310
- elapsed: number
311
- contextBaseline?: number
312
- ctrlDPending?: boolean
313
- }) {
314
- const cumulative = computeCumulativeTokens(exchanges)
315
- const cost = formatCost(cumulative.input, cumulative.output)
316
- const minutes = Math.floor(elapsed / 60)
317
- const seconds = elapsed % 60
318
- const elapsedStr = `${minutes}:${seconds.toString().padStart(2, "0")}`
319
-
320
- const CTX_W = 20
321
- const effectiveContext = Math.max(0, cumulative.currentContext - contextBaseline)
322
- const ctxFrac = effectiveContext / CONTEXT_WINDOW
323
- const ctxFilled = Math.round(Math.min(ctxFrac, 1) * CTX_W)
324
- const ctxPct = Math.round(ctxFrac * 100)
325
- const ctxColor = ctxPct > 100 ? "$error" : ctxPct > 80 ? "$warning" : "$primary"
326
- const ctxBar = "█".repeat(ctxFilled) + "░".repeat(CTX_W - ctxFilled)
327
-
328
- const keys = ctrlDPending ? "Ctrl-D again to exit" : compacting ? "compacting..." : "esc quit"
329
-
330
- return (
331
- <Box flexDirection="row" justifyContent="space-between" width="100%">
332
- <Text color="$muted" wrap="truncate">
333
- {elapsedStr}
334
- {" "}
335
- {keys}
336
- </Text>
337
- <Text color={ctxPct > 80 ? ctxColor : "$muted"} wrap="truncate">
338
- ctx {ctxBar} {ctxPct}%{" "}
339
- {cost}
340
- </Text>
341
- </Box>
342
- )
343
- }
344
-
345
- // ============================================================================
346
- // Footer — owns inputText state so typing doesn't re-render the parent
347
- // ============================================================================
348
-
349
- const AUTO_SUBMIT_DELAY = 10_000
350
-
351
- export function DemoFooter({
352
- controlRef,
353
- onSubmit,
354
- streamPhase,
355
- done,
356
- compacting,
357
- exchanges,
358
- contextBaseline = 0,
359
- ctrlDPending = false,
360
- nextMessage = "",
361
- autoTypingText = null,
362
- }: {
363
- controlRef: React.RefObject<FooterControl>
364
- onSubmit: (text: string) => void
365
- streamPhase: StreamPhase
366
- done: boolean
367
- compacting: boolean
368
- exchanges: Exchange[]
369
- contextBaseline?: number
370
- ctrlDPending?: boolean
371
- nextMessage?: string
372
- autoTypingText?: string | null
373
- }) {
374
- const terminalFocused = useTerminalFocused()
375
- const [inputText, setInputText] = useState("")
376
- const inputTextRef = useRef(inputText)
377
- inputTextRef.current = inputText
378
-
379
- const startRef = useRef(Date.now())
380
- const [elapsed, setElapsed] = useState(0)
381
- useEffect(() => {
382
- const timer = setInterval(() => setElapsed(Math.floor((Date.now() - startRef.current) / 1000)), 1000)
383
- return () => clearInterval(timer)
384
- }, [])
385
-
386
- const [randomIdx, setRandomIdx] = useState(() => Math.floor(Math.random() * RANDOM_USER_COMMANDS.length))
387
- const randomPlaceholder = RANDOM_USER_COMMANDS[randomIdx % RANDOM_USER_COMMANDS.length]!
388
- const effectiveMessage = nextMessage || randomPlaceholder
389
- const placeholder = !terminalFocused
390
- ? "Click to focus"
391
- : ctrlDPending
392
- ? "Press Ctrl-D again to exit"
393
- : effectiveMessage
394
-
395
- const handleSubmit = useCallback(
396
- (text: string) => {
397
- if (!text.trim() && effectiveMessage) {
398
- onSubmit(effectiveMessage)
399
- } else {
400
- onSubmit(text)
401
- }
402
- setInputText("")
403
- setRandomIdx((i) => i + 1)
404
- },
405
- [onSubmit, effectiveMessage],
406
- )
407
-
408
- // Expose submit() to parent — replaces the old getText/setText/getPlaceholder pattern
409
- controlRef.current = {
410
- submit: () => handleSubmit(inputTextRef.current),
411
- }
412
-
413
- // Auto-submit: if idle for AUTO_SUBMIT_DELAY, submit the placeholder message
414
- const autoSubmitRef = useRef<ReturnType<typeof setTimeout> | null>(null)
415
- useEffect(() => {
416
- if (autoSubmitRef.current) clearTimeout(autoSubmitRef.current)
417
- if (
418
- done ||
419
- compacting ||
420
- streamPhase !== "done" ||
421
- !effectiveMessage ||
422
- inputText ||
423
- autoTypingText ||
424
- !terminalFocused
425
- )
426
- return
427
- autoSubmitRef.current = setTimeout(() => onSubmit(effectiveMessage), AUTO_SUBMIT_DELAY)
428
- return () => {
429
- if (autoSubmitRef.current) clearTimeout(autoSubmitRef.current)
430
- }
431
- }, [done, compacting, streamPhase, effectiveMessage, inputText, autoTypingText, onSubmit])
432
-
433
- const displayText = autoTypingText ?? inputText
434
-
435
- return (
436
- <Box flexDirection="column" width="100%">
437
- <Text> </Text>
438
- <Box
439
- flexDirection="row"
440
- borderStyle="round"
441
- borderColor={!done && terminalFocused ? "$focusborder" : "$inputborder"}
442
- paddingX={1}
443
- >
444
- <Text bold color="$focusring">
445
- {"❯"}{" "}
446
- </Text>
447
- <Box flexShrink={1} flexGrow={1}>
448
- <TextInput
449
- value={displayText}
450
- onChange={autoTypingText ? () => {} : setInputText}
451
- onSubmit={handleSubmit}
452
- placeholder={placeholder}
453
- isActive={!done && !autoTypingText && terminalFocused}
454
- />
455
- </Box>
456
- </Box>
457
- <Box paddingX={2} width="100%">
458
- <StatusBar
459
- exchanges={exchanges}
460
- compacting={compacting}
461
- done={done}
462
- elapsed={elapsed}
463
- contextBaseline={contextBaseline}
464
- ctrlDPending={ctrlDPending}
465
- />
466
- </Box>
467
- </Box>
468
- )
469
- }
@@ -1,220 +0,0 @@
1
- /**
2
- * AI Chat — Coding Agent Demo
3
- *
4
- * Showcases ListView with streaming, tool calls, context tracking.
5
- * TEA state machine drives all animation; ListView caches completed
6
- * exchanges while live content stays in the React tree.
7
- *
8
- * Flags: --auto (auto-advance) --fast (skip animation) --stress (200 exchanges)
9
- */
10
-
11
- import React, { useCallback, useEffect, useRef, useMemo } from "react"
12
- import { Box, Text, Spinner, ListView, useTea, useWindowSize } from "silvery"
13
- import type { ListItemMeta } from "silvery"
14
- import { run, useInput, useExit, type Key } from "silvery/runtime"
15
- import type { ExampleMeta } from "../../_banner.js"
16
- import type { ScriptEntry } from "./types.js"
17
- import { SCRIPT, generateStressScript, CONTEXT_WINDOW } from "./script.js"
18
- import {
19
- INIT_STATE,
20
- createDemoUpdate,
21
- computeCumulativeTokens,
22
- getNextMessage,
23
- type DemoState,
24
- type DemoMsg,
25
- } from "./state.js"
26
- import { ExchangeItem, DemoFooter } from "./components.js"
27
- import type { FooterControl } from "./components.js"
28
-
29
- // Re-export for test consumers
30
- export { SCRIPT, generateStressScript, CONTEXT_WINDOW } from "./script.js"
31
- export type { ScriptEntry } from "./types.js"
32
- export type { Exchange, ToolCall } from "./types.js"
33
-
34
- export const meta: ExampleMeta = {
35
- name: "AI Coding Agent",
36
- description: "Coding agent showcase — ListView, streaming, context tracking",
37
- demo: true,
38
- features: ["ListView", "cache", "inline mode", "streaming", "OSC 8 links"],
39
- // TODO: Add OSC 133 marker support to ListView (km-silvery.listview-markers)
40
- }
41
-
42
- // ============================================================================
43
- // AIChat — TEA state machine + ListView
44
- // ============================================================================
45
-
46
- export function AIChat({
47
- script,
48
- autoStart,
49
- fastMode,
50
- }: {
51
- script: ScriptEntry[]
52
- autoStart: boolean
53
- fastMode: boolean
54
- }) {
55
- const exit = useExit()
56
- const { rows: termRows } = useWindowSize()
57
- const update = useMemo(() => createDemoUpdate(script, fastMode, autoStart), [script, fastMode, autoStart])
58
- const [state, send] = useTea(INIT_STATE, update)
59
- const footerControlRef = useRef<FooterControl>({ submit: () => {} })
60
-
61
- useEffect(() => send({ type: "mount" }), [send])
62
- useAutoCompact(state, send)
63
- useAutoExit(autoStart, state.done, exit)
64
- useKeyBindings(state, send, footerControlRef)
65
-
66
- const renderExchange = useCallback(
67
- (exchange: (typeof state.exchanges)[number], index: number, _meta: ListItemMeta) => {
68
- const isLatest = index === state.exchanges.length - 1
69
- return (
70
- <Box flexDirection="column">
71
- {index > 0 && <Text> </Text>}
72
- {state.compacting && isLatest && <CompactingOverlay />}
73
- {state.done && autoStart && isLatest && <SessionComplete />}
74
- <ExchangeItem
75
- exchange={exchange}
76
- streamPhase={state.streamPhase}
77
- revealFraction={state.revealFraction}
78
- pulse={state.pulse}
79
- isLatest={isLatest}
80
- isFirstInGroup={exchange.role !== (index > 0 ? state.exchanges[index - 1]!.role : null)}
81
- isLastInGroup={
82
- exchange.role !== (index < state.exchanges.length - 1 ? state.exchanges[index + 1]!.role : null)
83
- }
84
- />
85
- </Box>
86
- )
87
- },
88
- [state, autoStart],
89
- )
90
-
91
- return (
92
- <Box flexDirection="column" paddingX={1}>
93
- <ListView
94
- items={state.exchanges}
95
- getKey={(ex) => ex.id}
96
- height={termRows}
97
- estimateHeight={6}
98
- renderItem={renderExchange}
99
- scrollTo={state.exchanges.length - 1}
100
- cache={{
101
- mode: "virtual",
102
- isCacheable: (_ex, index) => index < state.exchanges.length - 1,
103
- }}
104
- listFooter={
105
- <DemoFooter
106
- controlRef={footerControlRef}
107
- onSubmit={(text) => send({ type: "submit", text })}
108
- streamPhase={state.streamPhase}
109
- done={state.done}
110
- compacting={state.compacting}
111
- exchanges={state.exchanges}
112
- contextBaseline={state.contextBaseline}
113
- ctrlDPending={state.ctrlDPending}
114
- nextMessage={getNextMessage(state, script, autoStart)}
115
- autoTypingText={state.autoTyping ? state.autoTyping.full.slice(0, state.autoTyping.revealed) : null}
116
- />
117
- }
118
- />
119
- </Box>
120
- )
121
- }
122
-
123
- // ============================================================================
124
- // Main
125
- // ============================================================================
126
-
127
- export async function main() {
128
- const args = process.argv.slice(2)
129
- const script = args.includes("--stress") ? generateStressScript() : SCRIPT
130
- const mode = args.includes("--inline") ? "inline" : "fullscreen"
131
-
132
- using handle = await run(
133
- <AIChat script={script} autoStart={args.includes("--auto")} fastMode={args.includes("--fast")} />,
134
- { mode: mode as "inline" | "fullscreen", focusReporting: true },
135
- )
136
- await handle.waitUntilExit()
137
- }
138
-
139
- if (import.meta.main) {
140
- main().catch(console.error)
141
- }
142
-
143
- // ============================================================================
144
- // Hooks
145
- // ============================================================================
146
-
147
- function useAutoCompact(state: DemoState, send: (msg: DemoMsg) => void) {
148
- useEffect(() => {
149
- if (state.done || state.compacting) return
150
- const cumulative = computeCumulativeTokens(state.exchanges)
151
- const effective = Math.max(0, cumulative.currentContext - state.contextBaseline)
152
- if (effective >= CONTEXT_WINDOW * 0.95) send({ type: "compact" })
153
- }, [state.exchanges, state.done, state.compacting, state.contextBaseline, send])
154
- }
155
-
156
- function useAutoExit(autoStart: boolean, done: boolean, exit: () => void) {
157
- useEffect(() => {
158
- if (!autoStart || !done) return
159
- const timer = setTimeout(exit, 1000)
160
- return () => clearTimeout(timer)
161
- }, [autoStart, done, exit])
162
- }
163
-
164
- function useKeyBindings(
165
- state: DemoState,
166
- send: (msg: DemoMsg) => void,
167
- footerControlRef: React.RefObject<FooterControl>,
168
- ) {
169
- const lastCtrlDRef = useRef(0)
170
-
171
- useInput((input: string, key: Key) => {
172
- if (key.escape) return "exit"
173
- if (key.ctrl && input === "d") {
174
- const now = Date.now()
175
- if (now - lastCtrlDRef.current < 500) return "exit"
176
- lastCtrlDRef.current = now
177
- send({ type: "setCtrlDPending", pending: true })
178
- return
179
- }
180
- if (lastCtrlDRef.current > 0) {
181
- lastCtrlDRef.current = 0
182
- send({ type: "setCtrlDPending", pending: false })
183
- }
184
- if (key.tab) {
185
- if (state.done || state.compacting) return
186
- footerControlRef.current.submit()
187
- return
188
- }
189
- if (key.ctrl && input === "l") {
190
- send({ type: "compact" })
191
- }
192
- })
193
- }
194
-
195
- // ============================================================================
196
- // Inline UI fragments
197
- // ============================================================================
198
-
199
- function CompactingOverlay() {
200
- return (
201
- <Box flexDirection="column" borderStyle="round" borderColor="$warning" paddingX={1} overflow="hidden">
202
- <Text color="$warning" bold>
203
- <Spinner type="arc" /> Compacting context
204
- </Text>
205
- <Text> </Text>
206
- <Text color="$muted">Freezing exchanges into terminal scrollback. Scroll up to review.</Text>
207
- </Box>
208
- )
209
- }
210
-
211
- function SessionComplete() {
212
- return (
213
- <Box flexDirection="column" borderStyle="round" borderColor="$success" paddingX={1}>
214
- <Text color="$success" bold>
215
- {"✓"} Session complete
216
- </Text>
217
- <Text color="$muted">Scroll up to review — colors, borders, and hyperlinks preserved in scrollback.</Text>
218
- </Box>
219
- )
220
- }