woopcode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CONTRIBUTING.md +329 -0
  2. package/LICENSE +21 -0
  3. package/README.md +582 -0
  4. package/cli.ts +17 -0
  5. package/commands/agent.tsx +139 -0
  6. package/commands/agentController.ts +138 -0
  7. package/commands/models.ts +21 -0
  8. package/commands/providers/index.ts +12 -0
  9. package/commands/providers/listProviders.ts +28 -0
  10. package/commands/providers/login.ts +28 -0
  11. package/commands/providers/logout.ts +33 -0
  12. package/commands/providers/setProvider.ts +34 -0
  13. package/config/authProvider.ts +54 -0
  14. package/config/client.ts +163 -0
  15. package/config/config.ts +118 -0
  16. package/config/conversation.json +1 -0
  17. package/config/models.json +50 -0
  18. package/config/paths.ts +96 -0
  19. package/config/providers.json +21 -0
  20. package/config/runtime.ts +130 -0
  21. package/config/systemPrompt.ts +54 -0
  22. package/config/types.ts +88 -0
  23. package/onboarding/FLOW.md +291 -0
  24. package/onboarding/index.ts +56 -0
  25. package/onboarding/providers.ts +47 -0
  26. package/onboarding/setupWizard.tsx +193 -0
  27. package/onboarding/test-reset.ts +55 -0
  28. package/package.json +86 -0
  29. package/tools/createFile.ts +24 -0
  30. package/tools/editFile.ts +85 -0
  31. package/tools/findFiles.ts +94 -0
  32. package/tools/grep.ts +68 -0
  33. package/tools/index.ts +26 -0
  34. package/tools/listFiles.ts +49 -0
  35. package/tools/readFile.ts +53 -0
  36. package/tools/runTests.ts +29 -0
  37. package/tools/terminal.ts +37 -0
  38. package/tools/writeFile.ts +75 -0
  39. package/tui/package.json +0 -0
  40. package/tui/src/app.tsx +60 -0
  41. package/tui/src/components/ApprovalFooter.tsx +29 -0
  42. package/tui/src/components/AsciiLogo.tsx +78 -0
  43. package/tui/src/components/BootScreen.tsx +76 -0
  44. package/tui/src/components/CapabilityRow.tsx +17 -0
  45. package/tui/src/components/CodeBlock.tsx +70 -0
  46. package/tui/src/components/DiffPreview.tsx +46 -0
  47. package/tui/src/components/DiffViewer.tsx +36 -0
  48. package/tui/src/components/HomeFooter.tsx +11 -0
  49. package/tui/src/components/HomeScreen.tsx +71 -0
  50. package/tui/src/components/InlineCode.tsx +13 -0
  51. package/tui/src/components/LogoReveal.tsx +158 -0
  52. package/tui/src/components/Markdown.tsx +280 -0
  53. package/tui/src/components/MessageRenderer.tsx +84 -0
  54. package/tui/src/components/PromptCard.tsx +52 -0
  55. package/tui/src/components/StreamingCursor.tsx +13 -0
  56. package/tui/src/components/ThinkingIndicator.tsx +14 -0
  57. package/tui/src/components/ToolStatus.tsx +26 -0
  58. package/tui/src/header.tsx +25 -0
  59. package/tui/src/hooks/useBootAnimation.ts +67 -0
  60. package/tui/src/hooks/useLogoAnimation.ts +114 -0
  61. package/tui/src/index.ts +5 -0
  62. package/tui/src/prompt.tsx +66 -0
  63. package/tui/src/statusBar.tsx +83 -0
  64. package/tui/src/store/ui-store.ts +234 -0
  65. package/tui/src/store/useUIStore.ts +9 -0
  66. package/tui/src/timeline.tsx +96 -0
  67. package/tui/src/types.ts +40 -0
@@ -0,0 +1,280 @@
1
+ import { Box, Text } from "ink";
2
+ import type { Token, Tokens } from "marked";
3
+ import { CodeBlock } from "./CodeBlock";
4
+ import { InlineCode } from "./InlineCode";
5
+ import type { ReactNode } from "react";
6
+
7
+ interface MarkdownProps {
8
+ tokens: Token[];
9
+ }
10
+
11
+ export function Markdown({ tokens }: MarkdownProps) {
12
+ return (
13
+ <Box flexDirection="column">
14
+ {tokens.map((token, i) => (
15
+ <MarkdownBlock key={i} token={token} />
16
+ ))}
17
+ </Box>
18
+ );
19
+ }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Block-level rendering
23
+ // ---------------------------------------------------------------------------
24
+
25
+ function MarkdownBlock({ token }: { token: Token }): ReactNode {
26
+ switch (token.type) {
27
+ case "heading": {
28
+ const t = token as Tokens.Heading;
29
+ const prefix = "#".repeat(t.depth) + " ";
30
+ const color =
31
+ t.depth === 1
32
+ ? "#5fafff"
33
+ : t.depth === 2
34
+ ? "#87afff"
35
+ : "#afafaf";
36
+ return (
37
+ <Box marginTop={1} marginBottom={1}>
38
+ <Text bold color={color}>
39
+ {prefix}
40
+ {renderInline(t.tokens)}
41
+ </Text>
42
+ </Box>
43
+ );
44
+ }
45
+
46
+ case "paragraph": {
47
+ const t = token as Tokens.Paragraph;
48
+ return (
49
+ <Box marginBottom={1}>
50
+ <Text>{renderInline(t.tokens)}</Text>
51
+ </Box>
52
+ );
53
+ }
54
+
55
+ case "code": {
56
+ const t = token as Tokens.Code;
57
+ return <CodeBlock code={t.text} language={t.lang || undefined} />;
58
+ }
59
+
60
+ case "blockquote": {
61
+ const t = token as Tokens.Blockquote;
62
+ return (
63
+ <Box
64
+ marginBottom={1}
65
+ borderStyle="bold"
66
+ borderLeft={true}
67
+ borderTop={false}
68
+ borderBottom={false}
69
+ borderRight={false}
70
+ borderColor="#666666"
71
+ paddingLeft={1}
72
+ >
73
+ <Box flexDirection="column">
74
+ {t.tokens.map((inner, i) => (
75
+ <MarkdownBlock key={i} token={inner} />
76
+ ))}
77
+ </Box>
78
+ </Box>
79
+ );
80
+ }
81
+
82
+ case "list": {
83
+ const t = token as Tokens.List;
84
+ return (
85
+ <Box flexDirection="column" marginBottom={1}>
86
+ {t.items.map((item, i) => {
87
+ const prefix = t.ordered
88
+ ? `${(typeof t.start === "number" ? t.start : 1) + i}. `
89
+ : "• ";
90
+ return <ListItem key={i} item={item} prefix={prefix} />;
91
+ })}
92
+ </Box>
93
+ );
94
+ }
95
+
96
+ case "table": {
97
+ const t = token as Tokens.Table;
98
+ const termWidth = (process.stdout.columns || 80) - 4;
99
+
100
+ // Extract visible plain text from a cell's inline tokens (for string-based layouts)
101
+ const cellPlain = (tokens: Token[]): string =>
102
+ tokens
103
+ .map((tk) => {
104
+ if ("tokens" in tk && Array.isArray(tk.tokens)) return cellPlain(tk.tokens as Token[]);
105
+ if ("text" in tk) return (tk as { text: string }).text;
106
+ return tk.raw ?? "";
107
+ })
108
+ .join("");
109
+
110
+ const colWidths = t.header.map((h, ci) => {
111
+ const hLen = cellPlain(h.tokens).length;
112
+ const maxCell = t.rows.reduce(
113
+ (max, row) => Math.max(max, cellPlain(row[ci]?.tokens ?? []).length),
114
+ 0,
115
+ );
116
+ return Math.max(hLen, maxCell, 3);
117
+ });
118
+
119
+ const pad = (str: string, width: number) =>
120
+ str + " ".repeat(Math.max(0, width - str.length));
121
+
122
+ const totalWidth = colWidths.reduce((sum, w) => sum + w + 3, 0) + 1;
123
+
124
+ if (totalWidth <= termWidth) {
125
+ // ── Horizontal table — string-based, plain text in cells ───────────
126
+ const top = "╭" + colWidths.map((w) => "─".repeat(w + 2)).join("┬") + "╮";
127
+ const mid = "├" + colWidths.map((w) => "─".repeat(w + 2)).join("┼") + "┤";
128
+ const bot = "╰" + colWidths.map((w) => "─".repeat(w + 2)).join("┴") + "╯";
129
+ const headerRow =
130
+ "│" +
131
+ t.header.map((h, i) => ` ${pad(cellPlain(h.tokens), colWidths[i]!)} `).join("│") +
132
+ "│";
133
+ const dataRows = t.rows.map(
134
+ (row) =>
135
+ "│" +
136
+ row.map((cell, i) => ` ${pad(cellPlain(cell.tokens), colWidths[i]!)} `).join("│") +
137
+ "│",
138
+ );
139
+ return (
140
+ <Box marginBottom={1}>
141
+ <Text>{[top, headerRow, mid, ...dataRows, bot].join("\n")}</Text>
142
+ </Box>
143
+ );
144
+ } else {
145
+ // ── Vertical fallback — use renderInline so formatting renders ─────
146
+ const labelWidth = Math.max(...t.header.map((h) => cellPlain(h.tokens).length));
147
+ const divider = "─".repeat(Math.min(termWidth, 40));
148
+ return (
149
+ <Box flexDirection="column" marginBottom={1}>
150
+ <Text dimColor>{divider}</Text>
151
+ {t.rows.map((row, ri) => (
152
+ <Box key={ri} flexDirection="column">
153
+ <Box flexDirection="column" marginY={1}>
154
+ {t.header.map((h, ci) => (
155
+ <Box key={ci}>
156
+ <Text dimColor>{` ${pad(cellPlain(h.tokens), labelWidth)} `}</Text>
157
+ <Text>{renderInline(row[ci]?.tokens ?? [])}</Text>
158
+ </Box>
159
+ ))}
160
+ </Box>
161
+ <Text dimColor>{divider}</Text>
162
+ </Box>
163
+ ))}
164
+ </Box>
165
+ );
166
+ }
167
+ }
168
+
169
+
170
+
171
+ case "hr": {
172
+ const hrWidth = Math.min((process.stdout.columns || 80) - 6, 60);
173
+ return (
174
+ <Box marginY={1}>
175
+ <Text dimColor>{"─".repeat(hrWidth)}</Text>
176
+ </Box>
177
+ );
178
+ }
179
+
180
+ case "space":
181
+ return null;
182
+
183
+ default:
184
+ // Unknown block types — show raw text if non-empty
185
+ if (token.raw?.trim()) {
186
+ return <Text>{token.raw}</Text>;
187
+ }
188
+ return null;
189
+ }
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // List items
194
+ // ---------------------------------------------------------------------------
195
+
196
+ function ListItem({
197
+ item,
198
+ prefix,
199
+ }: {
200
+ item: Tokens.ListItem;
201
+ prefix: string;
202
+ }) {
203
+ return (
204
+ <Box>
205
+ <Text dimColor>{prefix}</Text>
206
+ <Box flexDirection="column" flexShrink={1}>
207
+ {item.tokens.map((inner, j) => {
208
+ // In tight lists, unwrap paragraphs to avoid extra spacing
209
+ if (inner.type === "paragraph") {
210
+ const p = inner as Tokens.Paragraph;
211
+ return <Text key={j}>{renderInline(p.tokens)}</Text>;
212
+ }
213
+ // Tight list items use a bare "text" token instead of paragraph
214
+ if (inner.type === "text") {
215
+ const txt = inner as Tokens.Text;
216
+ if (txt.tokens && txt.tokens.length > 0) {
217
+ return <Text key={j}>{renderInline(txt.tokens)}</Text>;
218
+ }
219
+ return <Text key={j}>{txt.text}</Text>;
220
+ }
221
+ // Nested lists, code blocks, etc.
222
+ return <MarkdownBlock key={j} token={inner} />;
223
+ })}
224
+ </Box>
225
+ </Box>
226
+ );
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Inline rendering
231
+ // ---------------------------------------------------------------------------
232
+
233
+ function renderInline(tokens: Token[]): ReactNode[] {
234
+ return tokens.map((token, i) => {
235
+ switch (token.type) {
236
+ case "text": {
237
+ const t = token as Tokens.Text;
238
+ if (t.tokens && t.tokens.length > 0) {
239
+ return <Text key={i}>{renderInline(t.tokens)}</Text>;
240
+ }
241
+ return t.text;
242
+ }
243
+
244
+ case "strong": {
245
+ const t = token as Tokens.Strong;
246
+ return (
247
+ <Text key={i} bold>
248
+ {renderInline(t.tokens)}
249
+ </Text>
250
+ );
251
+ }
252
+
253
+ case "em": {
254
+ const t = token as Tokens.Em;
255
+ return (
256
+ <Text key={i} italic>
257
+ {renderInline(t.tokens)}
258
+ </Text>
259
+ );
260
+ }
261
+
262
+ case "codespan": {
263
+ const t = token as Tokens.Codespan;
264
+ return <InlineCode key={i} text={t.text} />;
265
+ }
266
+
267
+ case "link": {
268
+ const t = token as Tokens.Link;
269
+ // Render link text only, as requested
270
+ return <Text key={i}>{renderInline(t.tokens)}</Text>;
271
+ }
272
+
273
+ case "br":
274
+ return "\n";
275
+
276
+ default:
277
+ return token.raw;
278
+ }
279
+ });
280
+ }
@@ -0,0 +1,84 @@
1
+ import { useMemo } from "react";
2
+ import { Box } from "ink";
3
+ import { lexer, type Token } from "marked";
4
+ import { Markdown } from "./Markdown";
5
+ import { StreamingCursor } from "./StreamingCursor";
6
+
7
+ interface MessageRendererProps {
8
+ content: string;
9
+ streaming?: boolean;
10
+ }
11
+
12
+ /**
13
+ * Closes any unclosed code fence so marked.lexer() doesn't break
14
+ * while a message is still streaming.
15
+ *
16
+ * This is the only "healing" we do — marked handles incomplete
17
+ * bold, italic, lists, etc. gracefully on its own.
18
+ */
19
+ function healMarkdown(text: string): string {
20
+ if (!text) return text;
21
+
22
+ const lines = text.split("\n");
23
+ let inFence = false;
24
+ let fenceChar = "";
25
+ let fenceLen = 0;
26
+
27
+ for (const line of lines) {
28
+ const trimmed = line.trim();
29
+
30
+ if (!inFence) {
31
+ const match = trimmed.match(/^(`{3,}|~{3,})/);
32
+ if (match) {
33
+ inFence = true;
34
+ fenceChar = match[1]![0]!;
35
+ fenceLen = match[1]!.length;
36
+ }
37
+ } else {
38
+ // Closing fence: same char, at least same length, nothing else on the line
39
+ const closes = new RegExp(
40
+ `^${fenceChar}{${fenceLen},}\\s*$`,
41
+ ).test(trimmed);
42
+ if (closes) {
43
+ inFence = false;
44
+ }
45
+ }
46
+ }
47
+
48
+ // If we ended inside an open fence, close it
49
+ if (inFence) {
50
+ return text + "\n" + fenceChar.repeat(fenceLen);
51
+ }
52
+
53
+ return text;
54
+ }
55
+
56
+ export function MessageRenderer({ content, streaming }: MessageRendererProps) {
57
+ const tokens = useMemo((): Token[] => {
58
+ if (!content?.trim()) return [];
59
+
60
+ try {
61
+ const healed = healMarkdown(content);
62
+ return lexer(healed);
63
+ } catch {
64
+ // If marked fails for any reason, fall back to plain text
65
+ return [
66
+ {
67
+ type: "paragraph",
68
+ raw: content,
69
+ text: content,
70
+ tokens: [{ type: "text", raw: content, text: content }],
71
+ },
72
+ ] as Token[];
73
+ }
74
+ }, [content]);
75
+
76
+ if (tokens.length === 0) return null;
77
+
78
+ return (
79
+ <Box flexDirection="column">
80
+ <Markdown tokens={tokens} />
81
+ {streaming && <StreamingCursor />}
82
+ </Box>
83
+ );
84
+ }
@@ -0,0 +1,52 @@
1
+ import { Box } from "ink";
2
+ import { useEffect, useState } from "react";
3
+ import type { ReactNode } from "react";
4
+
5
+ const PLACEHOLDER_ROTATION_MS = 3500;
6
+ const TYPEWRITER_FRAME_MS = 24;
7
+
8
+ export interface PromptCardProps {
9
+ width: number;
10
+ examples: readonly string[];
11
+ children: (placeholder: string) => ReactNode;
12
+ }
13
+
14
+ export function PromptCard({ width, examples, children }: PromptCardProps) {
15
+ const placeholder = useRotatingPlaceholder(examples);
16
+
17
+ return (
18
+ <Box borderStyle="round" borderColor="gray" paddingX={1} width={width}>
19
+ {children(placeholder)}
20
+ </Box>
21
+ );
22
+ }
23
+
24
+ function useRotatingPlaceholder(examples: readonly string[]) {
25
+ const [index, setIndex] = useState(0);
26
+ const [visibleLength, setVisibleLength] = useState(0);
27
+ const example = examples[index] ?? "";
28
+
29
+ useEffect(() => {
30
+ if (examples.length < 2) return;
31
+
32
+ const interval = setInterval(() => {
33
+ setVisibleLength(0);
34
+ setIndex((current) => (current + 1) % examples.length);
35
+ }, PLACEHOLDER_ROTATION_MS);
36
+
37
+ return () => clearInterval(interval);
38
+ }, [examples]);
39
+
40
+ useEffect(() => {
41
+ if (visibleLength >= example.length) return;
42
+
43
+ const timer = setTimeout(
44
+ () => setVisibleLength((length) => length + 1),
45
+ TYPEWRITER_FRAME_MS,
46
+ );
47
+
48
+ return () => clearTimeout(timer);
49
+ }, [example, visibleLength]);
50
+
51
+ return example.slice(0, visibleLength);
52
+ }
@@ -0,0 +1,13 @@
1
+ import { useState, useEffect } from "react";
2
+ import { Text } from "ink";
3
+
4
+ export function StreamingCursor() {
5
+ const [visible, setVisible] = useState(true);
6
+
7
+ useEffect(() => {
8
+ const id = setInterval(() => setVisible((v) => !v), 500);
9
+ return () => clearInterval(id);
10
+ }, []);
11
+
12
+ return <Text dimColor>{visible ? "▌" : " "}</Text>;
13
+ }
@@ -0,0 +1,14 @@
1
+ import { Box, Text } from "ink";
2
+ import Spinner from "ink-spinner";
3
+
4
+ export function ThinkingIndicator() {
5
+ return (
6
+ <Box gap={1}>
7
+ <Text color="cyan">
8
+ <Spinner type="dots" />
9
+ </Text>
10
+
11
+ <Text color="gray">Thinking...</Text>
12
+ </Box>
13
+ );
14
+ }
@@ -0,0 +1,26 @@
1
+ import { Text } from "ink";
2
+ import Spinner from "ink-spinner";
3
+
4
+ interface ToolStatusProps {
5
+ status: "running" | "completed" | "failed";
6
+ }
7
+
8
+ export function ToolStatus({ status }: ToolStatusProps) {
9
+ if (status === "running") {
10
+ return (
11
+ <Text color="cyan">
12
+ <Spinner type="dots" /> Running
13
+ </Text>
14
+ );
15
+ }
16
+
17
+ if (status === "completed") {
18
+ return <Text color="green">✓ Completed</Text>;
19
+ }
20
+
21
+ if (status === "failed") {
22
+ return <Text color="red">✗ Failed</Text>;
23
+ }
24
+
25
+ return null;
26
+ }
@@ -0,0 +1,25 @@
1
+ import { Box, Text } from "ink";
2
+
3
+ interface HeaderProps {
4
+ branch: string;
5
+ provider: string;
6
+ }
7
+
8
+ export function Header({ branch, provider }: HeaderProps) {
9
+ return (
10
+ <Box justifyContent="space-between" width="100%">
11
+ <Box>
12
+ <Text bold color="cyan">
13
+ Woopcode
14
+ </Text>
15
+ <Text dimColor> / coding agent</Text>
16
+ </Box>
17
+
18
+ <Box>
19
+ <Text dimColor>{branch}</Text>
20
+ <Text dimColor> · </Text>
21
+ <Text color="cyan">{provider}</Text>
22
+ </Box>
23
+ </Box>
24
+ );
25
+ }
@@ -0,0 +1,67 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ export type BootPhase = "logo" | "steps" | "launching" | "done";
4
+
5
+ export interface BootAnimationState {
6
+ logoText: string;
7
+ loadingStep: number; // index of the currently-spinning step (-1 = none)
8
+ doneSteps: Set<number>;
9
+ phase: BootPhase;
10
+ }
11
+
12
+ const LOGO = "Woopcode";
13
+ const STEPS = ["Runtime", "Provider", "Tool Registry", "Repository Context"];
14
+
15
+ const LOGO_DURATION_MS = 600; // duration of LogoReveal animation
16
+ const STEP_HOLD_MS = 180; // how long spinner stays on a step before it completes
17
+ const STEP_GAP_MS = 220; // gap between steps starting (must be > STEP_HOLD_MS)
18
+ const LAUNCH_MS = 350; // "Launching..." shown for this long before onComplete
19
+
20
+ export function useBootAnimation(onComplete: () => void): BootAnimationState {
21
+ const [logoText, setLogoText] = useState("");
22
+ const [loadingStep, setLoadingStep] = useState(-1);
23
+ const [doneSteps, setDoneSteps] = useState<Set<number>>(new Set());
24
+ const [phase, setPhase] = useState<BootPhase>("logo");
25
+
26
+ useEffect(() => {
27
+ const timers: ReturnType<typeof setTimeout>[] = [];
28
+ const t = (ms: number, fn: () => void) => {
29
+ const id = setTimeout(fn, ms);
30
+ timers.push(id);
31
+ return id;
32
+ };
33
+
34
+ // ── Logo reveal ───────────────────────────────────────────────────────
35
+ // LogoReveal component handles the animation, we just wait for it
36
+ const afterLogo = LOGO_DURATION_MS;
37
+
38
+ // ── Steps ─────────────────────────────────────────────────────────────
39
+ t(afterLogo, () => setPhase("steps"));
40
+
41
+ STEPS.forEach((_, i) => {
42
+ const stepStart = afterLogo + i * STEP_GAP_MS;
43
+ t(stepStart, () => setLoadingStep(i));
44
+ t(stepStart + STEP_HOLD_MS, () => {
45
+ setDoneSteps((prev) => new Set(prev).add(i));
46
+ setLoadingStep(-1);
47
+ });
48
+ });
49
+
50
+ const afterSteps = afterLogo + STEPS.length * STEP_GAP_MS + STEP_HOLD_MS;
51
+
52
+ // ── Launching ─────────────────────────────────────────────────────────
53
+ t(afterSteps, () => setPhase("launching"));
54
+ t(afterSteps + LAUNCH_MS, () => {
55
+ setPhase("done");
56
+ onComplete();
57
+ });
58
+
59
+ return () => timers.forEach(clearTimeout);
60
+ // onComplete is stable (comes from useState setter reference in agent.tsx)
61
+ // eslint-disable-next-line react-hooks/exhaustive-deps
62
+ }, []);
63
+
64
+ return { logoText, loadingStep, doneSteps, phase };
65
+ }
66
+
67
+ export { STEPS };
@@ -0,0 +1,114 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+
3
+ export type AnimationMode = "highlight" | "typewriter" | "scan";
4
+
5
+ export interface LogoAnimationState {
6
+ /** The text that should be visible (for typewriter mode) */
7
+ visibleText: string;
8
+ /** Current position of the highlight/scan (0-1, normalized) */
9
+ highlightPosition: number;
10
+ /** Overall animation progress (0-1) */
11
+ progress: number;
12
+ /** Whether animation has completed */
13
+ isComplete: boolean;
14
+ }
15
+
16
+ interface UseLogoAnimationOptions {
17
+ text: string;
18
+ duration: number;
19
+ mode: AnimationMode;
20
+ onComplete?: () => void;
21
+ }
22
+
23
+ /**
24
+ * Custom animation hook for logo reveal effects.
25
+ * Uses interval-based timing for smooth animations in the terminal.
26
+ */
27
+ export function useLogoAnimation({
28
+ text,
29
+ duration,
30
+ mode,
31
+ onComplete,
32
+ }: UseLogoAnimationOptions): LogoAnimationState {
33
+ const [state, setState] = useState<LogoAnimationState>({
34
+ visibleText: mode === "typewriter" ? "" : text,
35
+ highlightPosition: 0,
36
+ progress: 0,
37
+ isComplete: false,
38
+ });
39
+
40
+ const startTimeRef = useRef<number>(Date.now());
41
+ const onCompleteCalledRef = useRef(false);
42
+
43
+ useEffect(() => {
44
+ startTimeRef.current = Date.now();
45
+
46
+ // Use 16ms interval for ~60fps
47
+ const intervalId = setInterval(() => {
48
+ const elapsed = Date.now() - startTimeRef.current;
49
+ const progress = Math.min(elapsed / duration, 1);
50
+
51
+ // Calculate state based on animation mode
52
+ const newState = calculateAnimationState(text, progress, mode);
53
+
54
+ setState(newState);
55
+
56
+ // Call onComplete exactly once when animation finishes
57
+ if (progress >= 1) {
58
+ if (!onCompleteCalledRef.current) {
59
+ onCompleteCalledRef.current = true;
60
+ onComplete?.();
61
+ }
62
+ clearInterval(intervalId);
63
+ }
64
+ }, 16);
65
+
66
+ return () => {
67
+ clearInterval(intervalId);
68
+ };
69
+ }, [text, duration, mode, onComplete]);
70
+
71
+ return state;
72
+ }
73
+
74
+ /**
75
+ * Calculate animation state based on progress and mode
76
+ */
77
+ function calculateAnimationState(
78
+ text: string,
79
+ progress: number,
80
+ mode: AnimationMode
81
+ ): LogoAnimationState {
82
+ const isComplete = progress >= 1;
83
+
84
+ switch (mode) {
85
+ case "typewriter": {
86
+ const numChars = Math.floor(progress * text.length);
87
+ return {
88
+ visibleText: text.slice(0, numChars + (isComplete ? text.length : 0)),
89
+ highlightPosition: progress,
90
+ progress,
91
+ isComplete,
92
+ };
93
+ }
94
+
95
+ case "scan":
96
+ case "highlight": {
97
+ // For highlight and scan, full text is visible, only position changes
98
+ return {
99
+ visibleText: text,
100
+ highlightPosition: progress,
101
+ progress,
102
+ isComplete,
103
+ };
104
+ }
105
+
106
+ default:
107
+ return {
108
+ visibleText: text,
109
+ highlightPosition: progress,
110
+ progress,
111
+ isComplete,
112
+ };
113
+ }
114
+ }
@@ -0,0 +1,5 @@
1
+ export { App } from "./app";
2
+ export { store } from "./store/ui-store";
3
+ export { LogoReveal } from "./components/LogoReveal";
4
+ export type { LogoRevealProps } from "./components/LogoReveal";
5
+ export type { AnimationMode } from "./hooks/useLogoAnimation";