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.
- package/CONTRIBUTING.md +329 -0
- package/LICENSE +21 -0
- package/README.md +582 -0
- package/cli.ts +17 -0
- package/commands/agent.tsx +139 -0
- package/commands/agentController.ts +138 -0
- package/commands/models.ts +21 -0
- package/commands/providers/index.ts +12 -0
- package/commands/providers/listProviders.ts +28 -0
- package/commands/providers/login.ts +28 -0
- package/commands/providers/logout.ts +33 -0
- package/commands/providers/setProvider.ts +34 -0
- package/config/authProvider.ts +54 -0
- package/config/client.ts +163 -0
- package/config/config.ts +118 -0
- package/config/conversation.json +1 -0
- package/config/models.json +50 -0
- package/config/paths.ts +96 -0
- package/config/providers.json +21 -0
- package/config/runtime.ts +130 -0
- package/config/systemPrompt.ts +54 -0
- package/config/types.ts +88 -0
- package/onboarding/FLOW.md +291 -0
- package/onboarding/index.ts +56 -0
- package/onboarding/providers.ts +47 -0
- package/onboarding/setupWizard.tsx +193 -0
- package/onboarding/test-reset.ts +55 -0
- package/package.json +86 -0
- package/tools/createFile.ts +24 -0
- package/tools/editFile.ts +85 -0
- package/tools/findFiles.ts +94 -0
- package/tools/grep.ts +68 -0
- package/tools/index.ts +26 -0
- package/tools/listFiles.ts +49 -0
- package/tools/readFile.ts +53 -0
- package/tools/runTests.ts +29 -0
- package/tools/terminal.ts +37 -0
- package/tools/writeFile.ts +75 -0
- package/tui/package.json +0 -0
- package/tui/src/app.tsx +60 -0
- package/tui/src/components/ApprovalFooter.tsx +29 -0
- package/tui/src/components/AsciiLogo.tsx +78 -0
- package/tui/src/components/BootScreen.tsx +76 -0
- package/tui/src/components/CapabilityRow.tsx +17 -0
- package/tui/src/components/CodeBlock.tsx +70 -0
- package/tui/src/components/DiffPreview.tsx +46 -0
- package/tui/src/components/DiffViewer.tsx +36 -0
- package/tui/src/components/HomeFooter.tsx +11 -0
- package/tui/src/components/HomeScreen.tsx +71 -0
- package/tui/src/components/InlineCode.tsx +13 -0
- package/tui/src/components/LogoReveal.tsx +158 -0
- package/tui/src/components/Markdown.tsx +280 -0
- package/tui/src/components/MessageRenderer.tsx +84 -0
- package/tui/src/components/PromptCard.tsx +52 -0
- package/tui/src/components/StreamingCursor.tsx +13 -0
- package/tui/src/components/ThinkingIndicator.tsx +14 -0
- package/tui/src/components/ToolStatus.tsx +26 -0
- package/tui/src/header.tsx +25 -0
- package/tui/src/hooks/useBootAnimation.ts +67 -0
- package/tui/src/hooks/useLogoAnimation.ts +114 -0
- package/tui/src/index.ts +5 -0
- package/tui/src/prompt.tsx +66 -0
- package/tui/src/statusBar.tsx +83 -0
- package/tui/src/store/ui-store.ts +234 -0
- package/tui/src/store/useUIStore.ts +9 -0
- package/tui/src/timeline.tsx +96 -0
- 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
|
+
}
|
package/tui/src/index.ts
ADDED