@silvery/examples 0.17.3 → 0.17.5

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 (111) hide show
  1. package/dist/UPNG-ShUlaTDh.mjs +5074 -0
  2. package/dist/__vite-browser-external-2447137e-Bopa5BFR.mjs +4 -0
  3. package/dist/_banner-A70_y2Vi.mjs +43 -0
  4. package/dist/ansi-0VXlUmNn.mjs +16397 -0
  5. package/dist/apng-B0gRaDVT.mjs +3 -0
  6. package/dist/apng-BTRDTfDW.mjs +68 -0
  7. package/dist/apps/aichat/index.mjs +1298 -0
  8. package/dist/apps/app-todo.mjs +138 -0
  9. package/dist/apps/async-data.mjs +203 -0
  10. package/dist/apps/cli-wizard.mjs +338 -0
  11. package/dist/apps/clipboard.mjs +197 -0
  12. package/dist/apps/components.mjs +863 -0
  13. package/dist/apps/data-explorer.mjs +482 -0
  14. package/dist/apps/dev-tools.mjs +396 -0
  15. package/dist/apps/explorer.mjs +697 -0
  16. package/dist/apps/gallery.mjs +765 -0
  17. package/dist/apps/inline-bench.mjs +115 -0
  18. package/dist/apps/kanban.mjs +279 -0
  19. package/dist/apps/layout-ref.mjs +186 -0
  20. package/dist/apps/outline.mjs +202 -0
  21. package/dist/apps/paste-demo.mjs +188 -0
  22. package/dist/apps/scroll.mjs +85 -0
  23. package/dist/apps/search-filter.mjs +286 -0
  24. package/dist/apps/selection.mjs +354 -0
  25. package/dist/apps/spatial-focus-demo.mjs +387 -0
  26. package/dist/apps/task-list.mjs +257 -0
  27. package/dist/apps/terminal-caps-demo.mjs +314 -0
  28. package/dist/apps/terminal.mjs +871 -0
  29. package/dist/apps/text-selection-demo.mjs +253 -0
  30. package/dist/apps/textarea.mjs +177 -0
  31. package/dist/apps/theme.mjs +660 -0
  32. package/dist/apps/transform.mjs +214 -0
  33. package/dist/apps/virtual-10k.mjs +421 -0
  34. package/dist/assets/resvgjs.darwin-arm64-BtufyGW1.node +0 -0
  35. package/dist/backends-Dj-11kZF.mjs +1179 -0
  36. package/dist/backends-U3QwStfO.mjs +3 -0
  37. package/dist/{cli.mjs → bin/cli.mjs} +15 -19
  38. package/dist/chunk-BSw8zbkd.mjs +37 -0
  39. package/dist/components/counter.mjs +47 -0
  40. package/dist/components/hello.mjs +30 -0
  41. package/dist/components/progress-bar.mjs +58 -0
  42. package/dist/components/select-list.mjs +84 -0
  43. package/dist/components/spinner.mjs +56 -0
  44. package/dist/components/text-input.mjs +61 -0
  45. package/dist/components/virtual-list.mjs +50 -0
  46. package/dist/flexily-zero-adapter-ByVzLTFP.mjs +3374 -0
  47. package/dist/gif-B6NGH5gs.mjs +3 -0
  48. package/dist/gif-CfkOF-iG.mjs +71 -0
  49. package/dist/gifenc-BI4ihP_T.mjs +728 -0
  50. package/dist/key-mapping-5oYQdAQE.mjs +3 -0
  51. package/dist/key-mapping-D4LR1go6.mjs +130 -0
  52. package/dist/layout/dashboard.mjs +1203 -0
  53. package/dist/layout/live-resize.mjs +302 -0
  54. package/dist/layout/overflow.mjs +69 -0
  55. package/dist/layout/text-layout.mjs +334 -0
  56. package/dist/node-nsrAOjH4.mjs +1083 -0
  57. package/dist/plugins-CT0DdV_E.mjs +3056 -0
  58. package/dist/resvg-js-Cnk2o49d.mjs +201 -0
  59. package/dist/src-9ZhfQyzD.mjs +814 -0
  60. package/dist/src-CUUOuRH6.mjs +5322 -0
  61. package/dist/src-jO3Zuzjj.mjs +23538 -0
  62. package/dist/usingCtx-CsEf0xO3.mjs +57 -0
  63. package/dist/yoga-adapter-BSQHuMV9.mjs +237 -0
  64. package/package.json +21 -14
  65. package/_banner.tsx +0 -60
  66. package/apps/aichat/components.tsx +0 -469
  67. package/apps/aichat/index.tsx +0 -220
  68. package/apps/aichat/script.ts +0 -460
  69. package/apps/aichat/state.ts +0 -325
  70. package/apps/aichat/types.ts +0 -19
  71. package/apps/app-todo.tsx +0 -201
  72. package/apps/async-data.tsx +0 -196
  73. package/apps/cli-wizard.tsx +0 -332
  74. package/apps/clipboard.tsx +0 -183
  75. package/apps/components.tsx +0 -658
  76. package/apps/data-explorer.tsx +0 -490
  77. package/apps/dev-tools.tsx +0 -395
  78. package/apps/explorer.tsx +0 -731
  79. package/apps/gallery.tsx +0 -653
  80. package/apps/inline-bench.tsx +0 -138
  81. package/apps/kanban.tsx +0 -265
  82. package/apps/layout-ref.tsx +0 -173
  83. package/apps/outline.tsx +0 -160
  84. package/apps/panes/index.tsx +0 -203
  85. package/apps/paste-demo.tsx +0 -185
  86. package/apps/scroll.tsx +0 -80
  87. package/apps/search-filter.tsx +0 -240
  88. package/apps/selection.tsx +0 -346
  89. package/apps/spatial-focus-demo.tsx +0 -372
  90. package/apps/task-list.tsx +0 -271
  91. package/apps/terminal-caps-demo.tsx +0 -317
  92. package/apps/terminal.tsx +0 -784
  93. package/apps/text-selection-demo.tsx +0 -193
  94. package/apps/textarea.tsx +0 -155
  95. package/apps/theme.tsx +0 -515
  96. package/apps/transform.tsx +0 -229
  97. package/apps/virtual-10k.tsx +0 -405
  98. package/apps/vterm-demo/index.tsx +0 -216
  99. package/components/counter.tsx +0 -49
  100. package/components/hello.tsx +0 -38
  101. package/components/progress-bar.tsx +0 -52
  102. package/components/select-list.tsx +0 -54
  103. package/components/spinner.tsx +0 -44
  104. package/components/text-input.tsx +0 -61
  105. package/components/virtual-list.tsx +0 -56
  106. package/dist/cli.d.mts +0 -1
  107. package/dist/cli.mjs.map +0 -1
  108. package/layout/dashboard.tsx +0 -953
  109. package/layout/live-resize.tsx +0 -282
  110. package/layout/overflow.tsx +0 -51
  111. 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
- await main()
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
- }