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.
- package/README.md +41 -145
- package/dist/chalk.js +3 -0
- package/dist/chalk.js.map +11 -0
- package/dist/index.js +340 -0
- package/dist/index.js.map +282 -0
- package/dist/ink.js +129 -0
- package/dist/ink.js.map +140 -0
- package/dist/runtime.js +394 -0
- package/dist/runtime.js.map +286 -0
- package/dist/theme.js +343 -0
- package/dist/theme.js.map +286 -0
- package/dist/ui/animation.js +3 -0
- package/dist/ui/animation.js.map +15 -0
- package/dist/ui/ansi.js +3 -0
- package/dist/ui/ansi.js.map +10 -0
- package/dist/ui/cli.js +8 -0
- package/dist/ui/cli.js.map +14 -0
- package/dist/ui/display.js +4 -0
- package/dist/ui/display.js.map +10 -0
- package/dist/ui/image.js +4 -0
- package/dist/ui/image.js.map +15 -0
- package/dist/ui/input.js +3 -0
- package/dist/ui/input.js.map +11 -0
- package/dist/ui/progress.js +8 -0
- package/dist/ui/progress.js.map +20 -0
- package/dist/ui/react.js +3 -0
- package/dist/ui/react.js.map +15 -0
- package/dist/ui/utils.js +3 -0
- package/dist/ui/utils.js.map +10 -0
- package/dist/ui/wrappers.js +14 -0
- package/dist/ui/wrappers.js.map +19 -0
- package/dist/ui.js +17 -0
- package/dist/ui.js.map +20 -0
- package/package.json +67 -15
- package/src/index.ts +67 -1
- package/src/runtime.ts +4 -0
- package/src/theme.ts +4 -0
- package/src/ui/animation.ts +2 -0
- package/src/ui/ansi.ts +2 -0
- package/src/ui/cli.ts +2 -0
- package/src/ui/display.ts +2 -0
- package/src/ui/image.ts +2 -0
- package/src/ui/input.ts +2 -0
- package/src/ui/progress.ts +2 -0
- package/src/ui/react.ts +2 -0
- package/src/ui/utils.ts +2 -0
- package/src/ui/wrappers.ts +2 -0
- package/src/ui.ts +4 -0
- package/examples/CLAUDE.md +0 -75
- package/examples/_banner.tsx +0 -60
- package/examples/cli.ts +0 -228
- package/examples/index.md +0 -101
- package/examples/inline/inline-nontty.tsx +0 -98
- package/examples/inline/inline-progress.tsx +0 -79
- package/examples/inline/inline-simple.tsx +0 -63
- package/examples/inline/scrollback.tsx +0 -185
- package/examples/interactive/_input-debug.tsx +0 -110
- package/examples/interactive/_stdin-test.ts +0 -71
- package/examples/interactive/_textarea-bare.tsx +0 -45
- package/examples/interactive/aichat/components.tsx +0 -468
- package/examples/interactive/aichat/index.tsx +0 -207
- package/examples/interactive/aichat/script.ts +0 -460
- package/examples/interactive/aichat/state.ts +0 -326
- package/examples/interactive/aichat/types.ts +0 -19
- package/examples/interactive/app-todo.tsx +0 -198
- package/examples/interactive/async-data.tsx +0 -208
- package/examples/interactive/cli-wizard.tsx +0 -332
- package/examples/interactive/clipboard.tsx +0 -183
- package/examples/interactive/components.tsx +0 -463
- package/examples/interactive/data-explorer.tsx +0 -506
- package/examples/interactive/dev-tools.tsx +0 -379
- package/examples/interactive/explorer.tsx +0 -747
- package/examples/interactive/gallery.tsx +0 -652
- package/examples/interactive/inline-bench.tsx +0 -136
- package/examples/interactive/kanban.tsx +0 -267
- package/examples/interactive/layout-ref.tsx +0 -185
- package/examples/interactive/outline.tsx +0 -171
- package/examples/interactive/paste-demo.tsx +0 -198
- package/examples/interactive/scroll.tsx +0 -77
- package/examples/interactive/search-filter.tsx +0 -240
- package/examples/interactive/task-list.tsx +0 -279
- package/examples/interactive/terminal.tsx +0 -798
- package/examples/interactive/textarea.tsx +0 -103
- package/examples/interactive/theme.tsx +0 -336
- package/examples/interactive/transform.tsx +0 -256
- package/examples/interactive/virtual-10k.tsx +0 -413
- package/examples/kitty/canvas.tsx +0 -519
- package/examples/kitty/generate-samples.ts +0 -236
- package/examples/kitty/image-component.tsx +0 -273
- package/examples/kitty/images.tsx +0 -604
- package/examples/kitty/input.tsx +0 -371
- package/examples/kitty/keys.tsx +0 -378
- package/examples/kitty/paint.tsx +0 -1017
- package/examples/layout/dashboard.tsx +0 -551
- package/examples/layout/live-resize.tsx +0 -290
- package/examples/layout/overflow.tsx +0 -51
- package/examples/playground/README.md +0 -69
- package/examples/playground/build.ts +0 -61
- package/examples/playground/index.html +0 -420
- package/examples/playground/playground-app.tsx +0 -416
- package/examples/runtime/elm-counter.tsx +0 -206
- package/examples/runtime/hello-runtime.tsx +0 -73
- package/examples/runtime/pipe-composition.tsx +0 -184
- package/examples/runtime/run-counter.tsx +0 -78
- package/examples/runtime/runtime-counter.tsx +0 -197
- package/examples/screenshots/generate.tsx +0 -563
- package/examples/scrollback-perf.tsx +0 -230
- package/examples/viewer.tsx +0 -654
- package/examples/web/build.ts +0 -365
- package/examples/web/canvas-app.tsx +0 -80
- package/examples/web/canvas.html +0 -89
- package/examples/web/dom-app.tsx +0 -81
- package/examples/web/dom.html +0 -113
- package/examples/web/showcase-app.tsx +0 -107
- package/examples/web/showcase.html +0 -34
- package/examples/web/showcases/index.tsx +0 -56
- package/examples/web/viewer-app.tsx +0 -555
- package/examples/web/viewer.html +0 -30
- package/examples/web/xterm-app.tsx +0 -105
- 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
|
-
}
|