@effect-tui/react 0.8.0 → 0.9.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/dist/src/components/Divider.d.ts.map +1 -1
- package/dist/src/components/Divider.js +1 -1
- package/dist/src/components/Divider.js.map +1 -1
- package/dist/src/components/Markdown.js +13 -13
- package/dist/src/components/Markdown.js.map +1 -1
- package/dist/src/components/MultilineTextInput.d.ts.map +1 -1
- package/dist/src/components/MultilineTextInput.js +1 -1
- package/dist/src/components/MultilineTextInput.js.map +1 -1
- package/dist/src/components/Table.d.ts.map +1 -1
- package/dist/src/components/Table.js +1 -1
- package/dist/src/components/Table.js.map +1 -1
- package/dist/src/components/TextInput.d.ts.map +1 -1
- package/dist/src/components/TextInput.js +1 -1
- package/dist/src/components/TextInput.js.map +1 -1
- package/dist/src/console/ConsoleCapture.d.ts +6 -0
- package/dist/src/console/ConsoleCapture.d.ts.map +1 -1
- package/dist/src/console/ConsoleCapture.js +23 -2
- package/dist/src/console/ConsoleCapture.js.map +1 -1
- package/dist/src/console/ConsolePopover.js +10 -10
- package/dist/src/console/ConsolePopover.js.map +1 -1
- package/dist/src/console/useConsole.d.ts.map +1 -1
- package/dist/src/console/useConsole.js +3 -5
- package/dist/src/console/useConsole.js.map +1 -1
- package/dist/src/debug/DiagnosticsPanel.js +1 -1
- package/dist/src/debug/DiagnosticsPanel.js.map +1 -1
- package/dist/src/dev/Toast.d.ts +3 -1
- package/dist/src/dev/Toast.d.ts.map +1 -1
- package/dist/src/dev/Toast.js +46 -10
- package/dist/src/dev/Toast.js.map +1 -1
- package/dist/src/dev.js +1 -1
- package/dist/src/dev.js.map +1 -1
- package/dist/src/hooks/use-scroll.d.ts.map +1 -1
- package/dist/src/hooks/use-scroll.js +14 -9
- package/dist/src/hooks/use-scroll.js.map +1 -1
- package/dist/src/hosts/box.d.ts +6 -0
- package/dist/src/hosts/box.d.ts.map +1 -1
- package/dist/src/hosts/box.js +15 -1
- package/dist/src/hosts/box.js.map +1 -1
- package/dist/src/hosts/canvas.js +1 -1
- package/dist/src/hosts/canvas.js.map +1 -1
- package/dist/src/hosts/codeblock.js +1 -1
- package/dist/src/hosts/codeblock.js.map +1 -1
- package/dist/src/inline/index.js +5 -5
- package/dist/src/inline/index.js.map +1 -1
- package/dist/src/renderer.d.ts.map +1 -1
- package/dist/src/renderer.js +23 -7
- package/dist/src/renderer.js.map +1 -1
- package/dist/src/visualize/index.js +3 -3
- package/dist/src/visualize/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/components/Divider.tsx +1 -1
- package/src/components/Markdown.tsx +13 -13
- package/src/components/MultilineTextInput.tsx +5 -5
- package/src/components/Table.tsx +2 -2
- package/src/components/TextInput.tsx +4 -4
- package/src/console/ConsoleCapture.ts +25 -2
- package/src/console/ConsolePopover.tsx +12 -12
- package/src/console/useConsole.ts +3 -6
- package/src/debug/DiagnosticsPanel.tsx +4 -4
- package/src/dev/Toast.tsx +79 -12
- package/src/dev.tsx +1 -1
- package/src/hooks/use-scroll.ts +18 -9
- package/src/hosts/box.ts +21 -1
- package/src/hosts/canvas.ts +1 -1
- package/src/hosts/codeblock.ts +1 -1
- package/src/inline/index.tsx +5 -5
- package/src/renderer.ts +22 -7
- package/src/visualize/index.tsx +11 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-tui/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "React bindings for @effect-tui/core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"prepublishOnly": "bun run typecheck && bun run build"
|
|
84
84
|
},
|
|
85
85
|
"dependencies": {
|
|
86
|
-
"@effect-tui/core": "^0.
|
|
86
|
+
"@effect-tui/core": "^0.9.1",
|
|
87
87
|
"@effect/platform": "^0.94.0",
|
|
88
88
|
"@effect/platform-bun": "^0.87.0",
|
|
89
89
|
"@effect/rpc": "^0.73.0",
|
|
@@ -17,7 +17,7 @@ export interface DividerProps {
|
|
|
17
17
|
* <Divider char="═" fg={Colors.brightBlue} />
|
|
18
18
|
* ```
|
|
19
19
|
*/
|
|
20
|
-
export function Divider({ char = "─", fg = Colors.gray(12) }: DividerProps) {
|
|
20
|
+
export function Divider({ char = "─", fg = Colors.ansi.gray(12) }: DividerProps) {
|
|
21
21
|
const { width } = useTerminalSize()
|
|
22
22
|
return <text fg={fg}>{char.repeat(width)}</text>
|
|
23
23
|
}
|
|
@@ -31,19 +31,19 @@ export interface MarkdownTheme {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
const defaultTheme: Required<MarkdownTheme> = {
|
|
34
|
-
h1: Colors.brightCyan,
|
|
35
|
-
h2: Colors.cyan,
|
|
36
|
-
h3: Colors.brightBlue,
|
|
37
|
-
bold: Colors.brightWhite,
|
|
38
|
-
italic: Colors.brightYellow,
|
|
39
|
-
code: Colors.brightGreen,
|
|
40
|
-
codeBg: Colors.gray(2),
|
|
41
|
-
link: Colors.brightBlue,
|
|
42
|
-
linkUrl: Colors.gray(10),
|
|
43
|
-
quote: Colors.gray(14),
|
|
44
|
-
quoteBorder: Colors.gray(8),
|
|
45
|
-
listMarker: Colors.gray(12),
|
|
46
|
-
text: Colors.gray(18),
|
|
34
|
+
h1: Colors.ansi.brightCyan,
|
|
35
|
+
h2: Colors.cyan(500),
|
|
36
|
+
h3: Colors.ansi.brightBlue,
|
|
37
|
+
bold: Colors.ansi.brightWhite,
|
|
38
|
+
italic: Colors.ansi.brightYellow,
|
|
39
|
+
code: Colors.ansi.brightGreen,
|
|
40
|
+
codeBg: Colors.ansi.gray(2),
|
|
41
|
+
link: Colors.ansi.brightBlue,
|
|
42
|
+
linkUrl: Colors.ansi.gray(10),
|
|
43
|
+
quote: Colors.ansi.gray(14),
|
|
44
|
+
quoteBorder: Colors.ansi.gray(8),
|
|
45
|
+
listMarker: Colors.ansi.gray(12),
|
|
46
|
+
text: Colors.ansi.gray(18),
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export interface MarkdownProps {
|
|
@@ -262,12 +262,12 @@ export function MultilineTextInput({
|
|
|
262
262
|
placeholder = "",
|
|
263
263
|
width,
|
|
264
264
|
height = 5,
|
|
265
|
-
fg = Colors.brightWhite,
|
|
265
|
+
fg = Colors.ansi.brightWhite,
|
|
266
266
|
bg,
|
|
267
|
-
cursorFg = Colors.black,
|
|
268
|
-
cursorBg = Colors.brightWhite,
|
|
269
|
-
placeholderFg = Colors.gray(10),
|
|
270
|
-
lineNumberFg = Colors.gray(12),
|
|
267
|
+
cursorFg = Colors.ansi.black,
|
|
268
|
+
cursorBg = Colors.ansi.brightWhite,
|
|
269
|
+
placeholderFg = Colors.ansi.gray(10),
|
|
270
|
+
lineNumberFg = Colors.ansi.gray(12),
|
|
271
271
|
onCancel,
|
|
272
272
|
onSubmit,
|
|
273
273
|
focused = true,
|
package/src/components/Table.tsx
CHANGED
|
@@ -175,8 +175,8 @@ function TableRoot<T>({
|
|
|
175
175
|
headerSeparator = false,
|
|
176
176
|
rowSeparator = false,
|
|
177
177
|
padding = 1,
|
|
178
|
-
borderColor = Colors.gray(12),
|
|
179
|
-
headerColor = Colors.gray(12),
|
|
178
|
+
borderColor = Colors.ansi.gray(12),
|
|
179
|
+
headerColor = Colors.ansi.gray(12),
|
|
180
180
|
children,
|
|
181
181
|
}: TableProps<T>) {
|
|
182
182
|
const columns = extractColumns<T>(children, data)
|
|
@@ -73,11 +73,11 @@ export function TextInput({
|
|
|
73
73
|
onChange,
|
|
74
74
|
placeholder = "",
|
|
75
75
|
width,
|
|
76
|
-
fg = Colors.brightWhite,
|
|
76
|
+
fg = Colors.ansi.brightWhite,
|
|
77
77
|
bg,
|
|
78
|
-
cursorFg = Colors.black,
|
|
79
|
-
cursorBg = Colors.brightWhite,
|
|
80
|
-
placeholderFg = Colors.gray(10),
|
|
78
|
+
cursorFg = Colors.ansi.black,
|
|
79
|
+
cursorBg = Colors.ansi.brightWhite,
|
|
80
|
+
placeholderFg = Colors.ansi.gray(10),
|
|
81
81
|
onSubmit,
|
|
82
82
|
onCancel,
|
|
83
83
|
focused = true,
|
|
@@ -120,12 +120,23 @@ export class ConsoleCapture extends EventEmitter {
|
|
|
120
120
|
private inConsoleMethod = false
|
|
121
121
|
// Track if current write is from the TUI renderer (should bypass capture)
|
|
122
122
|
private inRendererWrite = false
|
|
123
|
+
// Block stdout/stderr writes only after renderer is ready
|
|
124
|
+
// Until then, pass through to avoid swallowing startup errors
|
|
125
|
+
private blockingEnabled = false
|
|
123
126
|
|
|
124
127
|
constructor(options?: { maxEntries?: number }) {
|
|
125
128
|
super()
|
|
126
129
|
this.maxEntries = options?.maxEntries ?? 1000
|
|
127
130
|
}
|
|
128
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Enable blocking of stdout/stderr writes.
|
|
134
|
+
* Call this after the renderer is fully set up and rendering.
|
|
135
|
+
*/
|
|
136
|
+
enableBlocking(): void {
|
|
137
|
+
this.blockingEnabled = true
|
|
138
|
+
}
|
|
139
|
+
|
|
129
140
|
/**
|
|
130
141
|
* Write directly to stdout, bypassing capture.
|
|
131
142
|
* Used by the TUI renderer to output to terminal.
|
|
@@ -157,13 +168,17 @@ export class ConsoleCapture extends EventEmitter {
|
|
|
157
168
|
// Store original console
|
|
158
169
|
this.originalConsole = globalThis.console
|
|
159
170
|
|
|
171
|
+
// Enable blocking immediately - TUI owns the terminal
|
|
172
|
+
// Any output will be captured in the console panel, not displayed
|
|
173
|
+
this.blockingEnabled = true
|
|
174
|
+
|
|
160
175
|
// Intercept stdout.write - captures Effect.log, printf, etc.
|
|
161
176
|
process.stdout.write = ((
|
|
162
177
|
chunk: Uint8Array | string,
|
|
163
178
|
encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),
|
|
164
179
|
callback?: (err?: Error | null) => void,
|
|
165
180
|
): boolean => {
|
|
166
|
-
// If this write is from renderer
|
|
181
|
+
// If this write is from renderer, pass through
|
|
167
182
|
if (this.inRendererWrite) {
|
|
168
183
|
return originalStdoutWrite(chunk, encodingOrCallback as BufferEncoding, callback)
|
|
169
184
|
}
|
|
@@ -172,7 +187,11 @@ export class ConsoleCapture extends EventEmitter {
|
|
|
172
187
|
const text = typeof chunk === "string" ? chunk : chunk.toString()
|
|
173
188
|
this.captureRawOutput(text, "stdout")
|
|
174
189
|
}
|
|
175
|
-
//
|
|
190
|
+
// If blocking not enabled yet, pass through (allows startup errors to show)
|
|
191
|
+
if (!this.blockingEnabled) {
|
|
192
|
+
return originalStdoutWrite(chunk, encodingOrCallback as BufferEncoding, callback)
|
|
193
|
+
}
|
|
194
|
+
// Block write - TUI owns the terminal
|
|
176
195
|
const cb = typeof encodingOrCallback === "function" ? encodingOrCallback : callback
|
|
177
196
|
if (cb) cb()
|
|
178
197
|
return true
|
|
@@ -191,6 +210,10 @@ export class ConsoleCapture extends EventEmitter {
|
|
|
191
210
|
const text = typeof chunk === "string" ? chunk : chunk.toString()
|
|
192
211
|
this.captureRawOutput(text, "stderr")
|
|
193
212
|
}
|
|
213
|
+
// If blocking not enabled yet, pass through (allows startup errors to show)
|
|
214
|
+
if (!this.blockingEnabled) {
|
|
215
|
+
return originalStderrWrite(chunk, encodingOrCallback as BufferEncoding, callback)
|
|
216
|
+
}
|
|
194
217
|
const cb = typeof encodingOrCallback === "function" ? encodingOrCallback : callback
|
|
195
218
|
if (cb) cb()
|
|
196
219
|
return true
|
|
@@ -41,16 +41,16 @@ interface SelectionPoint {
|
|
|
41
41
|
// ─────────────────────────────────────────────────────────────
|
|
42
42
|
|
|
43
43
|
const LOG_COLORS: Record<LogLevel, ColorValue> = {
|
|
44
|
-
LOG: Colors.white,
|
|
45
|
-
INFO: Colors.cyan,
|
|
46
|
-
WARN: Colors.yellow,
|
|
47
|
-
ERROR: Colors.red,
|
|
48
|
-
DEBUG: Colors.gray(12),
|
|
44
|
+
LOG: Colors.ansi.white,
|
|
45
|
+
INFO: Colors.cyan(500),
|
|
46
|
+
WARN: Colors.yellow(500),
|
|
47
|
+
ERROR: Colors.red(500),
|
|
48
|
+
DEBUG: Colors.ansi.gray(12),
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
const TITLE_BG = Colors.gray(4)
|
|
52
|
-
const TITLE_FG = Colors.white
|
|
53
|
-
const CONTENT_BG = Colors.gray(2)
|
|
51
|
+
const TITLE_BG = Colors.ansi.gray(4)
|
|
52
|
+
const TITLE_FG = Colors.ansi.white
|
|
53
|
+
const CONTENT_BG = Colors.ansi.gray(2)
|
|
54
54
|
const SELECTION_BG = Colors.rgb(60, 90, 140)
|
|
55
55
|
|
|
56
56
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -421,7 +421,7 @@ export function ConsolePopover({
|
|
|
421
421
|
{before}
|
|
422
422
|
</text>
|
|
423
423
|
)}
|
|
424
|
-
<text fg={Colors.white} bg={SELECTION_BG}>
|
|
424
|
+
<text fg={Colors.ansi.white} bg={SELECTION_BG}>
|
|
425
425
|
{selected}
|
|
426
426
|
</text>
|
|
427
427
|
{after.length > 0 && (
|
|
@@ -450,11 +450,11 @@ export function ConsolePopover({
|
|
|
450
450
|
<vstack width={termWidth} height={popoverHeight}>
|
|
451
451
|
{/* Title bar */}
|
|
452
452
|
<hstack height={1} bg={TITLE_BG}>
|
|
453
|
-
<text fg={feedback ? Colors.green : TITLE_FG} bg={TITLE_BG}>
|
|
453
|
+
<text fg={feedback ? Colors.green(500) : TITLE_FG} bg={TITLE_BG}>
|
|
454
454
|
{` ${titleText}`}
|
|
455
455
|
</text>
|
|
456
456
|
<spacer />
|
|
457
|
-
<text fg={Colors.gray(14)} bg={TITLE_BG}>
|
|
457
|
+
<text fg={Colors.ansi.gray(14)} bg={TITLE_BG}>
|
|
458
458
|
{`${mode === "inline" ? inlineHints : hints} `}
|
|
459
459
|
</text>
|
|
460
460
|
</hstack>
|
|
@@ -463,7 +463,7 @@ export function ConsolePopover({
|
|
|
463
463
|
<scroll {...scrollProps} sticky>
|
|
464
464
|
<vstack>
|
|
465
465
|
{displayLines.length === 0 ? (
|
|
466
|
-
<text fg={Colors.gray(10)} bg={CONTENT_BG}>
|
|
466
|
+
<text fg={Colors.ansi.gray(10)} bg={CONTENT_BG}>
|
|
467
467
|
No console output yet
|
|
468
468
|
</text>
|
|
469
469
|
) : (
|
|
@@ -65,16 +65,13 @@ export function useConsole(options: UseConsoleOptions = {}): UseConsoleReturn {
|
|
|
65
65
|
const [visible, setVisible] = useState(initiallyVisible)
|
|
66
66
|
const [entries, setEntries] = useState<LogEntry[]>(() => capture.getEntries())
|
|
67
67
|
|
|
68
|
-
// Activate capture on mount
|
|
68
|
+
// Activate capture on mount (blocking is enabled immediately in activate())
|
|
69
69
|
useEffect(() => {
|
|
70
70
|
if (!capture.isActive) {
|
|
71
71
|
capture.activate()
|
|
72
72
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// Don't deactivate on unmount - other components may still use it
|
|
76
|
-
// The singleton handles cleanup on process exit
|
|
77
|
-
}
|
|
73
|
+
// Don't deactivate on unmount - other components may still use it
|
|
74
|
+
// The singleton handles cleanup on process exit
|
|
78
75
|
}, [capture])
|
|
79
76
|
|
|
80
77
|
// Subscribe to entries and errors
|
|
@@ -17,21 +17,21 @@ export function DiagnosticsPanel({ sampleMs = 200, title = "Diagnostics" }: Diag
|
|
|
17
17
|
|
|
18
18
|
return (
|
|
19
19
|
<vstack spacing={1}>
|
|
20
|
-
<text fg={Colors.cyan} bold>
|
|
20
|
+
<text fg={Colors.cyan(500)} bold>
|
|
21
21
|
{title} ({stats?.mode ?? "?"})
|
|
22
22
|
</text>
|
|
23
23
|
{stats ? (
|
|
24
24
|
<>
|
|
25
|
-
<text fg={Colors.gray(12)}>
|
|
25
|
+
<text fg={Colors.ansi.gray(12)}>
|
|
26
26
|
size {stats.width}×{stats.height} content {h} rows — {bytes} bytes/frame — {fps} fps
|
|
27
27
|
</text>
|
|
28
|
-
<text fg={Colors.gray(12)}>
|
|
28
|
+
<text fg={Colors.ansi.gray(12)}>
|
|
29
29
|
clear {phases?.clear.toFixed(2)}ms · layout {phases?.layout.toFixed(2)}ms · render{" "}
|
|
30
30
|
{phases?.render.toFixed(2)}ms · diff {phases?.diffAnsi.toFixed(2)}ms · write {phases?.write.toFixed(2)}ms
|
|
31
31
|
</text>
|
|
32
32
|
</>
|
|
33
33
|
) : (
|
|
34
|
-
<text fg={Colors.gray(10)}>Waiting for first frame…</text>
|
|
34
|
+
<text fg={Colors.ansi.gray(10)}>Waiting for first frame…</text>
|
|
35
35
|
)}
|
|
36
36
|
</vstack>
|
|
37
37
|
)
|
package/src/dev/Toast.tsx
CHANGED
|
@@ -2,19 +2,20 @@
|
|
|
2
2
|
// Beautiful, minimal notifications that appear at the top of the screen
|
|
3
3
|
|
|
4
4
|
import { Colors } from "@effect-tui/core"
|
|
5
|
-
import { createContext, type ReactNode, useCallback, useContext, useState } from "react"
|
|
6
|
-
import { useTerminalSize } from "../renderer-context.js"
|
|
5
|
+
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react"
|
|
7
6
|
|
|
8
7
|
// ─────────────────────────────────────────────────────────────
|
|
9
8
|
// Types
|
|
10
9
|
// ─────────────────────────────────────────────────────────────
|
|
11
10
|
|
|
12
|
-
export type ToastType = "success" | "info" | "warning" | "error"
|
|
11
|
+
export type ToastType = "success" | "info" | "warning" | "error" | "screenshot"
|
|
13
12
|
|
|
14
13
|
export interface Toast {
|
|
15
14
|
id: number
|
|
16
15
|
message: string
|
|
17
16
|
type: ToastType
|
|
17
|
+
/** Timestamp when the toast was created (for animations) */
|
|
18
|
+
createdAt: number
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export interface ToastContextValue {
|
|
@@ -49,6 +50,7 @@ const TOAST_STYLES: Record<
|
|
|
49
50
|
info: { bg: Colors.rgb(30, 50, 80), fg: Colors.rgb(140, 180, 230), icon: "ℹ" },
|
|
50
51
|
warning: { bg: Colors.rgb(80, 60, 20), fg: Colors.rgb(230, 200, 100), icon: "⚠" },
|
|
51
52
|
error: { bg: Colors.rgb(80, 30, 30), fg: Colors.rgb(230, 140, 140), icon: "✗" },
|
|
53
|
+
screenshot: { bg: Colors.rgb(30, 70, 40), fg: Colors.rgb(140, 230, 140), icon: "📷" },
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -62,7 +64,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|
|
62
64
|
|
|
63
65
|
const show = useCallback((message: string, type: ToastType = "info", durationMs = 2000) => {
|
|
64
66
|
const id = ++toastId
|
|
65
|
-
setToasts((prev) => [...prev, { id, message, type }])
|
|
67
|
+
setToasts((prev) => [...prev, { id, message, type, createdAt: Date.now() }])
|
|
66
68
|
|
|
67
69
|
setTimeout(() => {
|
|
68
70
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
@@ -76,30 +78,95 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|
|
76
78
|
return <ToastContext.Provider value={{ toasts, show, dismiss }}>{children}</ToastContext.Provider>
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
// ─────────────────────────────────────────────────────────────
|
|
82
|
+
// Screenshot Toast Animation
|
|
83
|
+
// ─────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
// Animation phases: camera → flash → success
|
|
86
|
+
type ScreenshotPhase = "camera" | "flash" | "success"
|
|
87
|
+
|
|
88
|
+
const SCREENSHOT_PHASES: Record<
|
|
89
|
+
ScreenshotPhase,
|
|
90
|
+
{ icon: string; bg: ReturnType<typeof Colors.rgb>; fg: ReturnType<typeof Colors.rgb> }
|
|
91
|
+
> = {
|
|
92
|
+
camera: {
|
|
93
|
+
icon: "📷",
|
|
94
|
+
bg: Colors.rgb(40, 50, 60),
|
|
95
|
+
fg: Colors.rgb(180, 200, 220),
|
|
96
|
+
},
|
|
97
|
+
flash: {
|
|
98
|
+
icon: "⚡",
|
|
99
|
+
bg: Colors.rgb(255, 255, 200), // Bright flash!
|
|
100
|
+
fg: Colors.rgb(60, 60, 40),
|
|
101
|
+
},
|
|
102
|
+
success: {
|
|
103
|
+
icon: "✓",
|
|
104
|
+
bg: Colors.rgb(30, 70, 40),
|
|
105
|
+
fg: Colors.rgb(140, 230, 140),
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function ScreenshotToast({ message, createdAt }: { message: string; createdAt: number }) {
|
|
110
|
+
const [phase, setPhase] = useState<ScreenshotPhase>("camera")
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
// Phase timing: camera (0-120ms) → flash (120-280ms) → success (280ms+)
|
|
114
|
+
const elapsed = Date.now() - createdAt
|
|
115
|
+
const cameraDelay = Math.max(0, 120 - elapsed)
|
|
116
|
+
const flashDelay = Math.max(0, 280 - elapsed)
|
|
117
|
+
|
|
118
|
+
const flashTimer = setTimeout(() => setPhase("flash"), cameraDelay)
|
|
119
|
+
const successTimer = setTimeout(() => setPhase("success"), flashDelay)
|
|
120
|
+
|
|
121
|
+
return () => {
|
|
122
|
+
clearTimeout(flashTimer)
|
|
123
|
+
clearTimeout(successTimer)
|
|
124
|
+
}
|
|
125
|
+
}, [createdAt])
|
|
126
|
+
|
|
127
|
+
const style = SCREENSHOT_PHASES[phase]
|
|
128
|
+
const content = ` ${style.icon} ${message} `
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<hstack>
|
|
132
|
+
<spacer />
|
|
133
|
+
<box bg={style.bg} padding={{ x: 1 }}>
|
|
134
|
+
<text fg={style.fg}>{content}</text>
|
|
135
|
+
</box>
|
|
136
|
+
</hstack>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
79
140
|
// ─────────────────────────────────────────────────────────────
|
|
80
141
|
// Toast Display Component
|
|
81
142
|
// ─────────────────────────────────────────────────────────────
|
|
82
143
|
|
|
83
144
|
export function ToastContainer() {
|
|
84
145
|
const { toasts } = useToast()
|
|
85
|
-
const { width } = useTerminalSize()
|
|
86
146
|
|
|
87
147
|
if (toasts.length === 0) return null
|
|
88
148
|
|
|
89
149
|
// Show only the most recent toast
|
|
90
150
|
const toast = toasts[toasts.length - 1]
|
|
151
|
+
|
|
152
|
+
// Special animated toast for screenshots
|
|
153
|
+
if (toast.type === "screenshot") {
|
|
154
|
+
return <ScreenshotToast message={toast.message} createdAt={toast.createdAt} />
|
|
155
|
+
}
|
|
156
|
+
|
|
91
157
|
const style = TOAST_STYLES[toast.type]
|
|
92
158
|
|
|
93
|
-
//
|
|
159
|
+
// Compact pill in upper right
|
|
94
160
|
const content = ` ${style.icon} ${toast.message} `
|
|
95
|
-
const padding = Math.max(0, Math.floor((width - content.length) / 2))
|
|
96
|
-
const paddedContent = " ".repeat(padding) + content + " ".repeat(width - padding - content.length)
|
|
97
161
|
|
|
98
|
-
//
|
|
162
|
+
// Position in upper right corner
|
|
99
163
|
return (
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
164
|
+
<hstack>
|
|
165
|
+
<spacer />
|
|
166
|
+
<box bg={style.bg} padding={{ x: 1 }}>
|
|
167
|
+
<text fg={style.fg}>{content}</text>
|
|
168
|
+
</box>
|
|
169
|
+
</hstack>
|
|
103
170
|
)
|
|
104
171
|
}
|
|
105
172
|
|
package/src/dev.tsx
CHANGED
|
@@ -327,7 +327,7 @@ function StatsOverlay({ sampleMs, title }: { sampleMs?: number; title?: string }
|
|
|
327
327
|
<vstack width={width} height={height}>
|
|
328
328
|
<hstack width={width}>
|
|
329
329
|
<spacer />
|
|
330
|
-
<box padding={1} border="rounded" borderColor={Colors.gray(8)} bg={Colors.gray(2)}>
|
|
330
|
+
<box padding={1} border="rounded" borderColor={Colors.ansi.gray(8)} bg={Colors.ansi.gray(2)}>
|
|
331
331
|
<DiagnosticsPanel sampleMs={sampleMs} title={title ?? "Renderer Stats"} />
|
|
332
332
|
</box>
|
|
333
333
|
</hstack>
|
package/src/hooks/use-scroll.ts
CHANGED
|
@@ -218,28 +218,37 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
218
218
|
const atStart = offset <= 0
|
|
219
219
|
const atEnd = offset >= maxOffset
|
|
220
220
|
|
|
221
|
+
const clampOffset = useCallback(
|
|
222
|
+
(value: number) => {
|
|
223
|
+
const clamped = Math.max(0, Math.min(maxOffset, value))
|
|
224
|
+
// Keep refs in sync so rapid events don't use stale state
|
|
225
|
+
offsetRef.current = clamped
|
|
226
|
+
wasAtEndRef.current = clamped >= maxOffset - 1
|
|
227
|
+
return clamped
|
|
228
|
+
},
|
|
229
|
+
[maxOffset],
|
|
230
|
+
)
|
|
231
|
+
|
|
221
232
|
// Clamp and set offset
|
|
222
233
|
const setOffset = useCallback(
|
|
223
234
|
(newOffset: number) => {
|
|
224
|
-
|
|
225
|
-
setOffsetRaw(clamped)
|
|
226
|
-
// Track if we're at end for sticky behavior
|
|
227
|
-
wasAtEndRef.current = clamped >= maxOffset - 1
|
|
235
|
+
setOffsetRaw(() => clampOffset(newOffset))
|
|
228
236
|
},
|
|
229
|
-
[
|
|
237
|
+
[clampOffset],
|
|
230
238
|
)
|
|
231
239
|
|
|
232
240
|
// Scroll by delta with accumulator for fractional scrolling
|
|
241
|
+
// Uses functional state updates to avoid stale closures during rapid scrolling
|
|
233
242
|
const scrollBy = useCallback(
|
|
234
243
|
(delta: number) => {
|
|
235
244
|
accumulatorRef.current += delta
|
|
236
245
|
const integerDelta = Math.trunc(accumulatorRef.current)
|
|
237
246
|
if (integerDelta !== 0) {
|
|
238
|
-
|
|
247
|
+
setOffsetRaw((prev) => clampOffset(prev + integerDelta))
|
|
239
248
|
accumulatorRef.current -= integerDelta
|
|
240
249
|
}
|
|
241
250
|
},
|
|
242
|
-
[
|
|
251
|
+
[clampOffset],
|
|
243
252
|
)
|
|
244
253
|
|
|
245
254
|
const scrollToStart = useCallback(() => {
|
|
@@ -270,9 +279,9 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
270
279
|
if (!sticky) return
|
|
271
280
|
const newMaxOffset = Math.max(0, contentSize - viewportSize)
|
|
272
281
|
if (wasAtEndRef.current && contentSize > 0) {
|
|
273
|
-
setOffsetRaw(newMaxOffset)
|
|
282
|
+
setOffsetRaw(() => clampOffset(newMaxOffset))
|
|
274
283
|
}
|
|
275
|
-
}, [sticky, contentSize, viewportSize])
|
|
284
|
+
}, [sticky, contentSize, viewportSize, clampOffset])
|
|
276
285
|
|
|
277
286
|
// Handle viewport size changes (reported by scroll component)
|
|
278
287
|
const handleViewportSize = useCallback(
|
package/src/hosts/box.ts
CHANGED
|
@@ -20,6 +20,10 @@ export interface BoxProps extends CommonProps {
|
|
|
20
20
|
border?: BorderKind
|
|
21
21
|
borderColor?: Color
|
|
22
22
|
bg?: Color
|
|
23
|
+
/** Title displayed on the top border */
|
|
24
|
+
title?: string
|
|
25
|
+
/** Title text color (defaults to borderColor) */
|
|
26
|
+
titleColor?: Color
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export class BoxHost extends SingleChildHost {
|
|
@@ -27,6 +31,8 @@ export class BoxHost extends SingleChildHost {
|
|
|
27
31
|
border: BorderKind = "none"
|
|
28
32
|
borderColor?: Color
|
|
29
33
|
bg?: Color
|
|
34
|
+
title?: string
|
|
35
|
+
titleColor?: Color
|
|
30
36
|
|
|
31
37
|
constructor(props: BoxProps, ctx: HostContext) {
|
|
32
38
|
super("box", props, ctx)
|
|
@@ -101,9 +107,21 @@ export class BoxHost extends SingleChildHost {
|
|
|
101
107
|
// Draw border
|
|
102
108
|
if (this.border !== "none" && w >= 2 && h >= 2) {
|
|
103
109
|
const chars = borderChars(this.border)
|
|
104
|
-
const borderFg = toColorValue(this.borderColor) ?? Colors.gray(8)
|
|
110
|
+
const borderFg = toColorValue(this.borderColor) ?? Colors.ansi.gray(8)
|
|
105
111
|
const borderStyle = palette.id({ fg: borderFg })
|
|
106
112
|
drawBorder(buffer, x, y, w, h, chars, borderStyle)
|
|
113
|
+
|
|
114
|
+
// Draw title on top border if present
|
|
115
|
+
if (this.title && w >= 5) {
|
|
116
|
+
const titleFg = toColorValue(this.titleColor) ?? borderFg
|
|
117
|
+
const titleStyle = palette.id({ fg: titleFg })
|
|
118
|
+
const maxTitleLen = w - 4 // Leave room for "─ " and " ─"
|
|
119
|
+
const displayTitle =
|
|
120
|
+
this.title.length > maxTitleLen ? this.title.slice(0, maxTitleLen - 1) + "…" : this.title
|
|
121
|
+
// Draw " Title " starting at x+2
|
|
122
|
+
const titleX = x + 2
|
|
123
|
+
buffer.drawText(titleX, y, ` ${displayTitle} `, titleStyle)
|
|
124
|
+
}
|
|
107
125
|
}
|
|
108
126
|
|
|
109
127
|
// Render single child
|
|
@@ -119,5 +137,7 @@ export class BoxHost extends SingleChildHost {
|
|
|
119
137
|
this.border = (props.border as BorderKind | undefined) ?? "none"
|
|
120
138
|
this.borderColor = props.borderColor as Color | undefined
|
|
121
139
|
this.bg = props.bg as Color | undefined
|
|
140
|
+
this.title = props.title as string | undefined
|
|
141
|
+
this.titleColor = props.titleColor as Color | undefined
|
|
122
142
|
}
|
|
123
143
|
}
|
package/src/hosts/canvas.ts
CHANGED
|
@@ -168,7 +168,7 @@ export class CanvasHost extends BaseHost {
|
|
|
168
168
|
// Draw border (with clipping)
|
|
169
169
|
if (border !== "none" && bw >= 2 && bh >= 2) {
|
|
170
170
|
const chars = borderChars(border)
|
|
171
|
-
const borderFg = toColorValue(opts?.borderColor) ?? toColorValue(opts?.fg) ?? Colors.gray(8)
|
|
171
|
+
const borderFg = toColorValue(opts?.borderColor) ?? toColorValue(opts?.fg) ?? Colors.ansi.gray(8)
|
|
172
172
|
const borderStyle = palette.id({ fg: borderFg })
|
|
173
173
|
drawBorder(buffer, px, py, bw, bh, chars, borderStyle, { ox, oy, w, h })
|
|
174
174
|
}
|
package/src/hosts/codeblock.ts
CHANGED
|
@@ -88,7 +88,7 @@ export class CodeBlockHost extends BaseHost {
|
|
|
88
88
|
|
|
89
89
|
if (this.lineNumbers) {
|
|
90
90
|
const gutterStyle = styleIdFromProps(palette, {
|
|
91
|
-
fg: this.lineNumberColor ?? Colors.gray(11),
|
|
91
|
+
fg: this.lineNumberColor ?? Colors.ansi.gray(11),
|
|
92
92
|
bg: this.lineNumberBackground ?? this.background,
|
|
93
93
|
})
|
|
94
94
|
const digits = String(i + 1).padStart(this.gutterWidth - 1, " ")
|
package/src/inline/index.tsx
CHANGED
|
@@ -41,19 +41,19 @@ function TaskList({ tasks, spinnerIndex }: TaskListProps) {
|
|
|
41
41
|
|
|
42
42
|
const color =
|
|
43
43
|
task.status === "running"
|
|
44
|
-
? Colors.brightYellow
|
|
44
|
+
? Colors.ansi.brightYellow
|
|
45
45
|
: task.status === "success"
|
|
46
|
-
? Colors.brightGreen
|
|
46
|
+
? Colors.ansi.brightGreen
|
|
47
47
|
: task.status === "failure"
|
|
48
|
-
? Colors.brightRed
|
|
49
|
-
: Colors.gray(10)
|
|
48
|
+
? Colors.ansi.brightRed
|
|
49
|
+
: Colors.ansi.gray(10)
|
|
50
50
|
|
|
51
51
|
return (
|
|
52
52
|
<hstack key={i} spacing={1}>
|
|
53
53
|
<text bold fg={color}>
|
|
54
54
|
{icon}
|
|
55
55
|
</text>
|
|
56
|
-
<text fg={task.status === "pending" ? Colors.gray(10) : undefined}>{task.label}</text>
|
|
56
|
+
<text fg={task.status === "pending" ? Colors.ansi.gray(10) : undefined}>{task.label}</text>
|
|
57
57
|
</hstack>
|
|
58
58
|
)
|
|
59
59
|
})}
|
package/src/renderer.ts
CHANGED
|
@@ -32,13 +32,28 @@ type HandledSignal = "SIGINT" | "SIGTERM"
|
|
|
32
32
|
export function createRenderer(options?: RendererOptions): TuiRenderer {
|
|
33
33
|
const fps = options?.fps ?? DEFAULT_FPS
|
|
34
34
|
// Use custom stdout if provided, otherwise use process.stdout with bypassed capture
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
let stdout: TuiWriteStream
|
|
36
|
+
if (options?.stdout) {
|
|
37
|
+
// Use custom stdout as-is (e.g., for testing with MockStdout)
|
|
38
|
+
stdout = options.stdout
|
|
39
|
+
} else {
|
|
40
|
+
// Create a proxy that bypasses console capture for writes
|
|
41
|
+
// This ensures Effect.log etc. don't corrupt the TUI
|
|
42
|
+
const terminalWrite = createTerminalWriter()
|
|
43
|
+
stdout = new Proxy(process.stdout as TuiWriteStream, {
|
|
44
|
+
get(target, prop) {
|
|
45
|
+
if (prop === "write") {
|
|
46
|
+
return terminalWrite
|
|
47
|
+
}
|
|
48
|
+
const value = (target as unknown as Record<string | symbol, unknown>)[prop]
|
|
49
|
+
// Bind methods to preserve `this` context (critical for .on(), .removeListener(), etc.)
|
|
50
|
+
if (typeof value === "function") {
|
|
51
|
+
return value.bind(target)
|
|
52
|
+
}
|
|
53
|
+
return value
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
}
|
|
42
57
|
const stdin: TuiReadStream = options?.stdin ?? process.stdin
|
|
43
58
|
const mode = options?.mode ?? "fullscreen"
|
|
44
59
|
const exitOnCtrlC = options?.exitOnCtrlC ?? true
|